diff --git a/.changeset/build-path-conformance.md b/.changeset/build-path-conformance.md new file mode 100644 index 000000000..afa20e9d0 --- /dev/null +++ b/.changeset/build-path-conformance.md @@ -0,0 +1,50 @@ +--- +"agent-bundle": minor +--- + +Move the generated-executable build path onto fully documented bundler +surfaces (Rspack/Rslib/Rsbuild conformance audit). + +- Generated wrapper entries and registry modules (the stdio MCP entry shell, + `main` process envelopes, `agent-bundle/mcp-apps` registries) are now + materialized as real files under the reserved `.agent-bundle-virtual/` + directory for the duration of one Rslib build — replacing the experimental + `rspack.experiments.VirtualModulesPlugin` and its undocumented + real-file-overlay of the framework's own module as the entry anchor. The + files never reach a published artifact and never count as authored source + provenance; emitted bundles keep their behavior byte for byte (the only + content shift is one scope-hoisting identifier now derived from the stable + generated-entry name instead of the framework's install-dependent bundle + filename). +- The self-contained-artifact invariant now closes the `output.externals` + hole: a `tools` hatch that externalizes a reserved specifier + (`agent-bundle/mcp-entry`, `agent-bundle/mcp-apps`, or any generated + registry name) fails the build with a hard diagnostic — statically for + string/RegExp/object externals, and via a post-build residual-import scan + of every emitted bundle for function-form externals. +- Dist cleaning is now a framework invariant rather than a profile default: + because generated sources are materialized under the output root, a + `tools.rsbuild.output.cleanDistPath: true` hatch would delete this build's + own entry modules and any sibling entry already emitted into the shared + staged root. It is pinned off after the hatch merge and asserted on the + resolved environment config. +- Pre-build inspection assertions are keyed by the documented Rslib `lib.id` + (`origin.environmentConfigs[id]` and the Rspack config `name`) instead of + relying on undocumented array ordering, and reserved aliases use Rspack's + exact-match (`$`) key form. +- Per-entry Rslib configs compose through Rslib's own documented + `mergeRslibConfig` (merged by `id`) with the framework invariant hooks + typed against each executing engine's own `Rspack.Configuration` and + returning the config, removing every `as never` cast; the one remaining + type seam between the public hatch types and Rslib's nested engine is a + single documented conversion. The dual-engine reality of the hatch — + Rslib's nested Rsbuild/Rspack (2.1.x line) on the executable path, the + workspace `@rsbuild/core` (2.2.x) on the MCP Apps path — is now documented + on `AgentBundleToolsConfig` and in the entry-conventions reference, + steering hatch authors to the `{ rspack }` utils argument instead of + importing `@rspack/core`. +- The unused direct `@rspack/core` dependency is dropped per Rslib guidance + (its types resolve through `@rsbuild/core`), the `lib` build's declaration + output is described accurately as a bundleless `.d.ts` graph, and the + `mcp run` docs no longer claim programmatic builds load the same `.env` + set (they load none). diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 5fe60507f..03cfe5067 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -144,8 +144,26 @@ The hatch is bounded: the framework invariant hook runs after the consumer's `tools.rspack`, and the resolved-config assertions still run after the merge. A hatch value that breaks an artifact contract (async chunks, output roots, self-containment) fails the build with a hard diagnostic instead of silently -overriding the contract. The hatch customizes *how code compiles*, never -*what the artifact promises*. +overriding the contract. Reserved module specifiers are protected the same +way: a hatch that externalizes `agent-bundle/mcp-entry` or a generated +registry specifier (such as `agent-bundle/mcp-apps`) fails the build with a +hard diagnostic — at config inspection for statically visible `externals`, +and through a post-build scan of the emitted bundle for function-form +`externals` — because generated executables must stay self-contained. The +hatch customizes *how code compiles*, never *what the artifact promises*. + +The hatch executes under two different bundler engine copies. Artifact +scripts, MCP entries, hook wrappers, and the package build compile through +Rslib, which runs the Rsbuild/Rspack versions nested inside `@rslib/core` +(currently the 2.1.x line); MCP App views compile through the +workspace-pinned `@rsbuild/core` (currently 2.2.x), until Rslib catches up to +the Rsbuild 2.2 line. So a hatch must never construct plugins or run +`instanceof` checks against an imported `@rspack/core`: a class imported from +a separately installed `@rspack/core` has a different identity than whichever +engine executes the config. Use instead the utils argument Rslib/Rsbuild pass +to `tools.rspack` mutator functions — +`tools: { rspack: (config, { rspack }) => { ... } }` — which always hands the +engine's own `rspack` object. ### `agent-bundle inspect --bundler` @@ -211,12 +229,15 @@ temporary artifact is built first. The runner loads the project-root `.env` set by default — rsbuild's `loadEnv` conventions (`.env`, `.env.local`, `.env.`, `.env..local`, with -`--mode` selecting the variants), the same files `createRslib` reads for the -same consumers at build time — so operator credentials configured for the -plugin reach a bare `mcp run` without a wrapper script. `--env-file ` -(repeatable, Node's `--env-file` dialect, later files win) replaces the -conventional set with exactly the named files, and `--no-env` skips the layer -entirely; a named file that cannot be read is an error, never a silent skip. +`--mode` selecting the variants) — so operator credentials configured for the +plugin reach a bare `mcp run` without a wrapper script. This is a +launch-time-only layer: the framework's programmatic Rslib builds never pass +`loadEnv` to `createRslib`, so `agent-bundle build` and the package build read +no `.env` file at all, and nothing from `.env` can leak into a compiled +artifact. `--env-file ` (repeatable, Node's `--env-file` dialect, later +files win) replaces the conventional set with exactly the named files, and +`--no-env` skips the layer entirely; a named file that cannot be read is an +error, never a silent skip. The child environment is composed from three layers. This table is the canonical precedence order (highest wins): diff --git a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts index 52fc81658..eba942718 100644 --- a/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts +++ b/examples/rsc-agent-runtime/tests/dev-invocation.integration.test.ts @@ -94,7 +94,8 @@ const buildInvocationEntry = async (compilerRoot: string, cwd = process.cwd()): config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }), cwd, }); - await rsbuild.build(); + const buildResult = await rsbuild.build(); + await buildResult.close(); return join(compilerRoot, 'rsc', 'dev', 'invoke.js'); }; @@ -1214,7 +1215,7 @@ setInterval(() => undefined, 1_000); const workerSource = join(copied.projectRoot, 'src', 'rsc', 'worker.tsx'); const source = await readFile(workerSource, 'utf8'); await writeFile(workerSource, source.replace('RSC worker received an invalid event', 'RSC worker received an invalid event generation-two')); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two', 15_000); const g2 = session.status().activeVector!.runtimeGenerationId; await writeFile(g1Worker, originalG1Worker); @@ -1265,7 +1266,7 @@ test('replays an exact historical surface after generation two removes it', asyn const definition = join(copied.projectRoot, 'src', 'definition.ts'); const source = await readFile(definition, 'utf8'); await writeFile(definition, source.replace(" host: 'claude',", " host: 'codex',")); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two', 15_000); const g2 = session.status().activeVector!.runtimeGenerationId; await expect(session.replay({ expectedGenerationId: g1, mode: 'exact', runId: run.id })) @@ -1302,7 +1303,7 @@ test('releases an exact historical lease when four active workers reject its adm const definition = join(copied.projectRoot, 'src', 'definition.ts'); await appendFile(definition, '\n// exact-lease-capacity-g2\n'); - await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two'); + await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two', 15_000); const g2 = session.status().activeVector!.runtimeGenerationId; const marker = join(storageRoot, 'blocked-exact-lease-workers.txt'); const worker = join(storageRoot, 'generation-store', 'generations', g2, 'rsc', 'rsc', 'index.js'); diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index e63feb375..9d773619c 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -70,7 +70,6 @@ "@rsbuild/plugin-react": "2.1.0", "@rslib/core": "0.23.2", "@rslint/core": "0.8.2", - "@rspack/core": "2.2.1", "@rstackjs/load-config": "0.1.2", "acorn": "8.18.0", "ajv": "8.20.0", diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 47fb20c0b..225e17940 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -1,4 +1,4 @@ -import { createRsbuild, mergeRsbuildConfig, type RsbuildConfig } from '@rsbuild/core'; +import { createRsbuild, mergeRsbuildConfig, type RsbuildConfig, type Rspack } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; import { readFile } from 'node:fs/promises'; import { extname, resolve } from 'node:path'; @@ -42,6 +42,8 @@ const assertResolvedViewConfig = ( } for (const environment of Object.values(environments)) { if ( + // Cleaning the shared staged target root would delete sibling outputs. + environment.output.cleanDistPath !== false || environment.output.filenameHash !== false || environment.output.inlineScripts !== true || environment.output.inlineStyles !== true || @@ -138,7 +140,7 @@ export const composeMcpAppsRsbuildConfig = ( sources: readonly Pick[], options: { readonly outDir: string; readonly tools?: AgentBundleToolsConfig }, ): RsbuildConfig => { - const profile = { + const profile: RsbuildConfig = { environments: Object.fromEntries(sources.map((source) => [source.name, { ...(usesReactSyntax(source.source) ? { plugins: [pluginReact()] } : {}), html: { @@ -150,7 +152,6 @@ export const composeMcpAppsRsbuildConfig = ( logLevel: 'silent' as const, mode: 'production' as const, output: { - cleanDistPath: false, dataUriLimit: Number.MAX_SAFE_INTEGER, distPath: { html: 'mcp-apps', root: options.outDir }, filename: { css: '[name].css', html: '[name].html', js: '[name].js' }, @@ -164,15 +165,19 @@ export const composeMcpAppsRsbuildConfig = ( server: { publicDir: false }, splitChunks: false, }; - const enforceInvariants = (config: { output: { asyncChunks?: boolean } }): void => { - config.output.asyncChunks = false; + const enforceInvariants = (config: Rspack.Configuration): Rspack.Configuration => { + config.output = { ...config.output, asyncChunks: false }; + return config; }; - return mergeRsbuildConfig( - profile as never, - ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild as never]), - ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } } as never]), - { tools: { rspack: enforceInvariants } } as never, - ) as RsbuildConfig; + return mergeRsbuildConfig( + 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 } }, + ); }; export const compileMcpApps = async ( diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index e25a7afd8..2cec424b1 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -12,10 +12,11 @@ import { buildWithRslib, type RslibEntry } from './rslib.ts'; /** * The framework-owned npm package build: `bin` entries become self-executing * `dist/bin/.js` bundles (shebang + executable bit) and the `lib` entry - * becomes `dist/.js` (+ bundled `.d.ts`), all through the same Rslib - * synthesis, invariant assertions, and staged atomic publication as artifact - * executables. This is the build audiobook-curator previously needed a second - * bundler config, a tsconfig, and a hand-written bin shim to produce. + * becomes `dist/.js` (+ a bundleless `.d.ts` declaration graph), all + * through the same Rslib synthesis, invariant assertions, and staged atomic + * publication as artifact executables. This is the build audiobook-curator + * previously needed a second bundler config, a tsconfig, and a hand-written + * bin shim to produce. */ const binShebang = '#!/usr/bin/env node'; diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 6abf2c46e..2693d0ab3 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -1,13 +1,14 @@ -// Rslib re-exports its own rspack; installing @rspack/core separately risks -// version conflicts (https://rslib.rs/api/javascript-api/core). -import { mergeRsbuildConfig } from '@rsbuild/core'; -import { createRslib, rspack, type LibConfig } from '@rslib/core'; -import { readFile, realpath } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +// Rslib re-exports its own Rsbuild/Rspack stack (values and types alike); +// installing @rspack/core separately risks version conflicts +// (https://rslib.rs/api/javascript-api/core). +import { createRslib, mergeRslibConfig, type LibConfig, type Rspack } from '@rslib/core'; +import { init, parse } from 'es-module-lexer'; +import { mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; import { isErrno } from '../core/errors.ts'; import type { AgentBundleToolsConfig } from '../core/types.ts'; +import { mcpEntryRuntimeSpecifier } from './entry-shell.ts'; import { collectBundledOutputEvidence, type BundledOutputEvidence } from './provenance.ts'; export interface RslibVirtualModule { @@ -16,7 +17,12 @@ export interface RslibVirtualModule { } export interface RslibEntry { - /** Module specifiers aliased onto existing on-disk modules (e.g. the mcp-entry runtime shell). */ + /** + * Module specifiers aliased onto existing on-disk modules (e.g. the + * mcp-entry runtime shell). Applied as exact-match (`$`) bundler aliases — + * generated code imports exactly these specifiers — and reserved: the + * consumer tools hatch may not externalize them. + */ readonly aliases?: Readonly>; /** Raw JS banner prepended to the emitted bundle (e.g. a bin shebang). */ readonly banner?: string; @@ -34,12 +40,213 @@ export interface RslibEntry { type RslibInstance = Awaited>; type RslibLibConfig = LibConfig; +type RslibToolsRspack = NonNullable['rspack']>; interface RslibDependencies { readonly createRslib?: (options: Parameters[0]) => Promise>; } -const entryAnchor = fileURLToPath(import.meta.url); +/** + * The reserved directory (under each build's output root) where generated + * module sources — wrapper entries and registry modules — are materialized + * as real files for the duration of one Rslib build. Materialized files are + * excluded from authored-source provenance and removed after the build, so + * they never reach a published artifact. + */ +const generatedModulesDirname = '.agent-bundle-virtual'; + +/** + * The tools escape hatch is typed against the workspace `@rsbuild/core` (the + * engine of the MCP Apps path), while this build path executes under the + * Rsbuild/Rspack copies nested in `@rslib/core` (the dual-engine reality + * documented on {@link AgentBundleToolsConfig}). These two functions are the + * single deliberate crossing between those type universes; everything else + * in this module stays inside Rslib's own types. + */ +const asRslibEnvironmentFragment = ( + fragment: NonNullable, +): Omit => fragment as Omit; +const asRslibRspackHatch = ( + hatch: NonNullable, +): RslibToolsRspack => hatch as RslibToolsRspack; + +const entryLibId = (entry: Pick): string => `agent-bundle-${entry.name}`; + +// join (not resolve) so `inspect --bundler`'s tokenized output roots +// (`/`) stay tokens instead of resolving against the cwd. +const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string => + join(outputRoot, generatedModulesDirname, `${entry.name}-entry.mjs`); + +const materializedVirtualModules = ( + outputRoot: string, + entry: RslibEntry, +): readonly { readonly name: string; readonly path: string; readonly source: string }[] => + (entry.virtualModules ?? []).map((module, index) => ({ + ...module, + path: join(outputRoot, generatedModulesDirname, `${entry.name}-${index}.mjs`), + })); + +/** Every generated module one entry materializes to disk for its build. */ +const plannedGeneratedModules = ( + entry: RslibEntry, + outputRoot: string, +): readonly { readonly path: string; readonly source: string }[] => [ + ...(entry.virtualSource === undefined + ? [] + : [{ path: generatedEntryModulePath(outputRoot, entry), source: entry.virtualSource }]), + ...materializedVirtualModules(outputRoot, entry).map(({ path, source }) => ({ path, source })), +]; + +/** + * The module specifiers one entry's emitted bundle must inline: the runtime + * shell alias targets and generated registry modules, plus the mcp-entry + * runtime specifier itself (public API even for hand-rolled entries). A + * consumer tools hatch externalizing any of these would break the + * self-contained artifact contract. + */ +const reservedSpecifiers = (entry: RslibEntry): readonly string[] => Object.freeze([...new Set([ + mcpEntryRuntimeSpecifier, + ...Object.keys(entry.aliases ?? {}), + ...(entry.virtualModules ?? []).map((module) => module.name), +])]); + +const reservedExternalError = (specifier: string): Error => new Error( + `The tools escape hatch must not externalize the reserved specifier ${JSON.stringify(specifier)}; ` + + 'generated executables stay self-contained.', +); + +/** + * Finds a reserved specifier that a statically inspectable `externals` value + * (string, RegExp, object map, or arrays thereof) would externalize. An + * object entry whose value is `false` explicitly opts out of + * externalization, so it is not a violation. Function externals cannot be + * inspected here; {@link guardReservedExternals} intercepts those at build + * time and the post-build residual-import scan fails closed behind both. + */ +const reservedExternalsViolation = (externals: unknown, reserved: readonly string[]): string | undefined => { + if (externals === undefined || externals === null) return undefined; + if (Array.isArray(externals)) { + for (const item of externals) { + const violation = reservedExternalsViolation(item, reserved); + if (violation !== undefined) return violation; + } + return undefined; + } + if (typeof externals === 'string') return reserved.includes(externals) ? externals : undefined; + if (externals instanceof RegExp) return reserved.find((specifier) => externals.test(specifier)); + if (typeof externals === 'object') { + return Object.entries(externals).find(([key, value]) => reserved.includes(key) && value !== false)?.[0]; + } + return undefined; +}; + +/** A function external's non-result: not externalized, resolution continues. */ +const isExternalizedResult = (result: unknown): boolean => result !== undefined && result !== false; + +/** + * Wraps every function-form external so that resolving a reserved specifier + * as external fails the build instead of silently breaking the + * self-contained artifact. Merely consulting the function for a reserved + * request stays legal (the engine consults every external for every + * request); only a positive externalization is a violation. Both the + * callback and the promise calling conventions are preserved, including the + * arity the engine uses to distinguish them. Violations are also reported + * through `onViolation`, because an error delivered inside the external + * factory surfaces only as a generic bundler failure — the caller uses the + * report to raise the actionable diagnostic. + */ +const guardReservedExternals = ( + externals: unknown, + reserved: readonly string[], + onViolation: (specifier: string) => void, +): unknown => { + if (Array.isArray(externals)) return externals.map((item) => guardReservedExternals(item, reserved, onViolation)); + if (typeof externals !== 'function') return externals; + const external = externals as ( + data: { readonly request?: string }, + callback?: (error?: Error | null, result?: unknown, type?: string) => void, + ) => unknown; + const reservedRequestOf = (data: { readonly request?: string }): string | undefined => + typeof data.request === 'string' && reserved.includes(data.request) ? data.request : undefined; + if (external.length <= 1) { + return async (data: { readonly request?: string }): Promise => { + const result = await external(data); + const request = reservedRequestOf(data); + if (request !== undefined && isExternalizedResult(result)) { + onViolation(request); + throw reservedExternalError(request); + } + return result; + }; + } + return ( + data: { readonly request?: string }, + callback: (error?: Error | null, result?: unknown, type?: string) => void, + ): unknown => external(data, (error, result, type) => { + const request = reservedRequestOf(data); + if ((error === undefined || error === null) && request !== undefined && isExternalizedResult(result)) { + onViolation(request); + callback(reservedExternalError(request)); + return; + } + callback(error, result, type); + }); +}; + +/** + * Finds an alias key (from rslib defaults or the consumer hatch) that could + * capture a reserved specifier: an exact key for the specifier, its + * exact-match (`$`) form, or a prefix key covering it. The framework's own + * reserved aliases are exempted by the caller. + */ +const reservedAliasViolation = ( + alias: Readonly> | undefined, + reserved: readonly string[], + frameworkKeys: ReadonlySet, +): string | undefined => Object.keys(alias ?? {}).find((key) => { + if (frameworkKeys.has(key)) return false; + const exact = key.endsWith('$'); + const base = exact ? key.slice(0, -1) : key; + return reserved.some((specifier) => specifier === base || (!exact && specifier.startsWith(`${base}/`))); +}); + +/** + * Fail-closed self-containment check on the emitted bundles themselves: + * no reserved specifier may survive bundling as a live import. This is the + * belt behind the static externals check and the function-external guard. + * The bundle is parsed as an ES module (the emitted format by contract), so + * string literals or comments that merely mention a reserved specifier are + * not violations. + */ +const assertNoResidualReservedImports = async ( + entries: readonly RslibEntry[], + outputRoot: string, +): Promise => { + await init; + await Promise.all(entries.map(async (entry) => { + const reserved = reservedSpecifiers(entry); + const bundle = await readFile(resolve(outputRoot, entry.outputRelativePath), 'utf8'); + // A bin banner shebang is legal for Node but not for the ESM lexer. + const source = bundle.startsWith('#!') ? bundle.slice(bundle.indexOf('\n') + 1) : bundle; + let imports: ReturnType[0]; + try { + [imports] = parse(source); + } catch { + throw new Error(`Generated executable ${JSON.stringify(entry.outputRelativePath)} did not parse as an ES module.`); + } + const residual = imports + .map((record) => record.n) + .find((specifier) => specifier !== undefined && reserved.includes(specifier)); + if (residual !== undefined) { + throw new Error( + `Generated executable ${JSON.stringify(entry.outputRelativePath)} is not self-contained: ` + + `the reserved module specifier ${JSON.stringify(residual)} survived bundling. ` + + 'The tools escape hatch must not externalize ' + + `${reserved.map((specifier) => JSON.stringify(specifier)).join(', ')}.`, + ); + } + })); +}; const declaredDependencyRoots = async (cwd: string): Promise => { let bytes: string; @@ -68,30 +275,82 @@ const declaredDependencyRoots = async (cwd: string): Promise return Object.freeze(roots.filter((root): root is string => root !== undefined)); }; +interface InspectedBundlerConfig { + readonly externals?: unknown; + readonly name?: string; + readonly output?: { readonly asyncChunks?: boolean; readonly path?: string }; + readonly resolve?: { readonly alias?: unknown }; + readonly target?: false | string | readonly string[]; +} + +const aliasRecordOf = (config: InspectedBundlerConfig): Readonly> | undefined => { + const alias = config.resolve?.alias; + return typeof alias === 'object' && alias !== null ? alias as Readonly> : undefined; +}; + +interface InspectedEnvironmentConfig { + readonly output?: { readonly cleanDistPath?: unknown }; +} + const assertExecutableConfig = ( entries: readonly RslibEntry[], - configs: readonly { readonly output?: { readonly asyncChunks?: boolean; readonly path?: string }; readonly plugins?: readonly unknown[]; readonly target?: false | string | readonly string[] }[], + inspection: { + readonly bundlerConfigs: readonly InspectedBundlerConfig[]; + readonly environmentConfigs: Readonly>; + }, outputRoot: string, ): void => { - if (configs.length !== entries.length) { + if ( + inspection.bundlerConfigs.length !== entries.length || + Object.keys(inspection.environmentConfigs).length !== entries.length + ) { throw new Error('Rslib did not resolve one environment for every generated executable.'); } - for (const [index, config] of configs.entries()) { - const entry = entries[index]!; + for (const entry of entries) { + const id = entryLibId(entry); + const environment = inspection.environmentConfigs[id] as InspectedEnvironmentConfig | undefined; + if (environment === undefined) { + throw new Error('Rslib did not resolve one environment for every generated executable.'); + } + // Dist cleaning would delete this build's own materialized generated + // sources and any sibling entry already emitted into the shared staged + // root, so the composed invariant pins it off after the hatch merge. + if (environment.output?.cleanDistPath !== false) { + throw new Error('Rslib resolved a generated executable environment that would clean its own output root.'); + } + // Rslib documents lib.id as the generated environment key and names each + // Rspack config after it; array position carries no documented meaning. + const matches = inspection.bundlerConfigs.filter((config) => config.name === id); + if (matches.length !== 1) { + throw new Error('Rslib did not resolve one environment for every generated executable.'); + } + const config = matches[0]!; const target = Array.isArray(config.target) ? config.target : [config.target]; if (config.output?.asyncChunks !== false || config.output.path !== outputRoot || !target.some((value) => value === 'node')) { throw new Error('Rslib resolved an invalid generated executable configuration.'); } - const entryHasVirtualModules = - entry.virtualSource !== undefined || (entry.virtualModules?.length ?? 0) > 0; - if (entryHasVirtualModules) { - const hasVirtualModule = config.plugins?.some( - (plugin) => plugin instanceof rspack.experiments.VirtualModulesPlugin, - ); - if (!hasVirtualModule) { - throw new Error('Rslib resolved a generated executable environment without its virtual module.'); + const expectedAliases = { + ...entry.aliases, + ...Object.fromEntries(materializedVirtualModules(outputRoot, entry) + .map((module) => [module.name, module.path])), + }; + const alias = aliasRecordOf(config); + const frameworkAliasKeys = new Set(Object.keys(expectedAliases).map((name) => `${name}$`)); + for (const [name, moduleTarget] of Object.entries(expectedAliases)) { + if (alias?.[`${name}$`] !== moduleTarget) { + throw new Error('Rslib resolved a generated executable environment without its reserved module aliases.'); } } + const reserved = reservedSpecifiers(entry); + const aliasViolation = reservedAliasViolation(alias, reserved, frameworkAliasKeys); + if (aliasViolation !== undefined) { + throw new Error( + `The tools escape hatch must not alias the reserved specifier matched by ${JSON.stringify(aliasViolation)}; ` + + 'generated executables resolve reserved modules through the framework aliases.', + ); + } + const violation = reservedExternalsViolation(config.externals, reserved); + if (violation !== undefined) throw reservedExternalError(violation); } }; @@ -99,42 +358,69 @@ 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. `buildWithRslib` lowers exactly this composition and - * `inspect --bundler` surfaces it, so the two can never drift. + * 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. */ export const composeEntryLibConfig = ( entry: RslibEntry, - options: { readonly outputRoot: string; readonly tools?: AgentBundleToolsConfig }, + options: { + /** Receives reserved specifiers that a function-form external resolved at build time. */ + readonly onReservedExternal?: (specifier: string) => void; + readonly outputRoot: string; + readonly tools?: AgentBundleToolsConfig; + }, ): LibConfig => { + const libId = entryLibId(entry); const virtualSource = entry.virtualSource; - const virtualModules = (entry.virtualModules ?? []).map((module, index) => ({ - ...module, - path: resolve(options.outputRoot, '.agent-bundle-virtual', `${entry.name}-${index}.mjs`), - })); - const hasVirtualModules = virtualSource !== undefined || virtualModules.length > 0; + const virtualModules = materializedVirtualModules(options.outputRoot, entry); const aliases = entry.aliases ?? {}; - const enforceInvariants = (config: { - output: { asyncChunks?: boolean }; - plugins: unknown[]; - resolve: { alias?: Record }; - }): void => { - config.output.asyncChunks = false; - if (hasVirtualModules || Object.keys(aliases).length > 0) { - config.resolve.alias = { - ...config.resolve.alias, - ...aliases, - ...Object.fromEntries(virtualModules.map((module) => [module.name, module.path])), + const reserved = reservedSpecifiers(entry); + const frameworkAliasKeys = new Set([ + ...Object.keys(aliases).map((name) => `${name}$`), + ...virtualModules.map((module) => `${module.name}$`), + ]); + const enforceInvariants = (config: Rspack.Configuration): Rspack.Configuration => { + config.output = { ...config.output, asyncChunks: false }; + const aliasViolation = reservedAliasViolation( + config.resolve?.alias as Readonly> | undefined, + reserved, + frameworkAliasKeys, + ); + if (aliasViolation !== undefined) { + throw new Error( + `The tools escape hatch must not alias the reserved specifier matched by ${JSON.stringify(aliasViolation)}; ` + + 'generated executables resolve reserved modules through the framework aliases.', + ); + } + if (virtualModules.length > 0 || Object.keys(aliases).length > 0) { + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve?.alias, + // Exact-match ($) keys per the resolve.alias contract: generated + // code imports exactly these specifiers, never subpaths beneath. + ...Object.fromEntries(Object.entries(aliases).map(([name, target]) => [`${name}$`, target])), + ...Object.fromEntries(virtualModules.map((module) => [`${module.name}$`, module.path])), + }, }; } - if (hasVirtualModules) { - config.plugins.push(new rspack.experiments.VirtualModulesPlugin({ - ...(virtualSource === undefined ? {} : { [entryAnchor]: virtualSource }), - ...Object.fromEntries(virtualModules.map((module) => [module.path, module.source])), - })); + const violation = reservedExternalsViolation(config.externals, reserved); + if (violation !== undefined) throw reservedExternalError(violation); + if (config.externals !== undefined) { + // Function externals resolve requests at build time, so they are + // guarded there rather than inspected here. + config.externals = guardReservedExternals( + config.externals, + reserved, + options.onReservedExternal ?? (() => undefined), + ) as typeof config.externals; } + return config; }; const profile: RslibLibConfig = { - id: `agent-bundle-${entry.name}`, + id: libId, autoExternal: false, ...(entry.banner === undefined ? {} : { banner: { js: entry.banner } }), bundle: true, @@ -151,7 +437,6 @@ export const composeEntryLibConfig = ( splitChunks: false, syntax: 'es2022', output: { - cleanDistPath: false, distPath: { root: options.outputRoot }, filename: { js: entry.outputRelativePath }, filenameHash: false, @@ -162,17 +447,32 @@ export const composeEntryLibConfig = ( }, source: { entry: { - [entry.name]: virtualSource === undefined ? entry.source : entryAnchor, + [entry.name]: virtualSource === undefined ? entry.source : generatedEntryModulePath(options.outputRoot, entry), }, ...(entry.tsconfigPath === undefined ? {} : { tsconfigPath: entry.tsconfigPath }), }, }; - return mergeRsbuildConfig( - profile as never, - ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild as never]), - ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } } as never]), - { tools: { rspack: enforceInvariants } } as never, - ) as RslibLibConfig; + 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 this build's own materialized generated sources (they live + // under the output root) and any sibling entry 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 } }] }, + ); + 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)}.`); + } + return lib; }; export const buildWithRslib = async (options: { @@ -191,37 +491,63 @@ export const buildWithRslib = async (options: { } const dependencyRoots = await declaredDependencyRoots(options.cwd); - const rslib = await (dependencies.createRslib ?? createRslib)({ - cwd: options.cwd, - config: { - logLevel: options.logLevel ?? 'silent', - lib: options.entries.map((entry) => composeEntryLibConfig(entry, { - outputRoot: options.outputRoot, - ...(options.tools === undefined ? {} : { tools: options.tools }), - })), - }, - }); - - const inspection = await rslib.inspectConfig(); - assertExecutableConfig(options.entries, inspection.origin.bundlerConfigs, options.outputRoot); - let result: Awaited> | undefined; + // Generated wrapper entries and registry modules become real files for the + // duration of the build — the stable, documented alternative to serving + // them through the experimental VirtualModulesPlugin — and are removed + // before artifact listing/publication. + const generatedModulesRoot = resolve(options.outputRoot, generatedModulesDirname); + const generatedModules = options.entries.flatMap((entry) => plannedGeneratedModules(entry, options.outputRoot)); + let result: Awaited> | undefined; try { - result = await rslib.build(); - return collectBundledOutputEvidence({ + if (generatedModules.length > 0) { + await mkdir(generatedModulesRoot, { recursive: true }); + await Promise.all(generatedModules.map((module) => writeFile(module.path, module.source, 'utf8'))); + } + + const reservedExternalViolations: string[] = []; + const rslib = await (dependencies.createRslib ?? createRslib)({ + cwd: options.cwd, + config: { + logLevel: options.logLevel ?? 'silent', + lib: options.entries.map((entry) => composeEntryLibConfig(entry, { + onReservedExternal: (specifier) => reservedExternalViolations.push(specifier), + outputRoot: options.outputRoot, + ...(options.tools === undefined ? {} : { tools: options.tools }), + })), + }, + }); + + const inspection = await rslib.inspectConfig(); + assertExecutableConfig(options.entries, inspection.origin, options.outputRoot); + try { + result = await rslib.build(); + } catch (error) { + // A violation raised inside the external factory reaches here only as + // a generic bundler failure; surface the actionable diagnostic. + if (reservedExternalViolations.length > 0) throw reservedExternalError(reservedExternalViolations[0]!); + throw error; + } + if (reservedExternalViolations.length > 0) throw reservedExternalError(reservedExternalViolations[0]!); + const evidence = collectBundledOutputEvidence({ expectedAssets: options.entries.map((entry) => ({ path: entry.outputRelativePath, sourceInputs: entry.sourceInputs, })), ignoredSourcePaths: [ - entryAnchor, - resolve(options.outputRoot, '.agent-bundle-virtual'), + generatedModulesRoot, ...(options.ignoredSourcePaths ?? []), ...dependencyRoots, ], projectRoot: options.cwd, stats: result.stats, }); + await assertNoResidualReservedImports(options.entries, options.outputRoot); + return evidence; } finally { - await result?.close(); + try { + await result?.close(); + } finally { + await rm(generatedModulesRoot, { force: true, recursive: true }); + } } }; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 14af61dbe..b12409140 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -117,7 +117,11 @@ export type AgentBundleBinConfig = false | Readonly ...`), which always hands the executing + * engine's own `rspack` object. */ export interface AgentBundleToolsConfig { /** Rsbuild environment-config fragment merged after the synthesized profile. */ diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 1f9b82147..25ee2179b 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -7,6 +7,7 @@ import { spawn } from 'node:child_process'; import { createJiti } from 'jiti'; import { build as buildArtifact, type BuildOptions as LowLevelBuildOptions, type BuildResult } from '../src/build/build.ts'; +import { buildWithRslib, type RslibEntry } from '../src/build/rslib.ts'; import { publishArtifact } from '../src/build/emit.ts'; import type { TargetHookContract } from '../src/adapters/hook-contract.ts'; import { parseArtifactManifest, serializeArtifactManifest } from '../src/build/manifest.ts'; @@ -1075,3 +1076,149 @@ it('restores the existing artifact when publication fails after backup', async ( await rm(root, { force: true, recursive: true }); } }); + +/** + * A minimal project exercising both reserved-specifier mechanisms of one + * generated executable: an alias onto an on-disk runtime module and a + * materialized generated registry module. + */ +const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; readonly root: string }> => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-reserved-specifiers-')); + const sourceRoot = join(root, 'src'); + await mkdir(sourceRoot, { recursive: true }); + await writeFile(join(root, 'package.json'), '{"type":"module"}\n'); + await writeFile(join(sourceRoot, 'shell.ts'), "export const marker = 'inlined-runtime-shell';\n"); + await writeFile(join(sourceRoot, 'entry.ts'), [ + "import { marker } from 'agent-bundle/mcp-entry';", + "import registry from 'agent-bundle/mcp-apps';", + // A reserved specifier mentioned as data, not imported: the residual-import + // scan parses the emitted bundle instead of grepping it, so this survives + // into the output without failing the self-containment check. + "const mentioned = 'agent-bundle/mcp-entry';", + 'console.log(marker, registry, mentioned);', + '', + ].join('\n')); + return { + entry: { + aliases: { 'agent-bundle/mcp-entry': join(sourceRoot, 'shell.ts') }, + name: 'reserved-probe', + outputRelativePath: 'scripts/reserved-probe.mjs', + source: join(sourceRoot, 'entry.ts'), + sourceInputs: [join(sourceRoot, 'entry.ts')], + virtualModules: [{ name: 'agent-bundle/mcp-apps', source: "export default 'generated-registry';\n" }], + }, + root, + }; +}; + +it('inlines reserved specifiers through exact-match aliases and materialized generated modules', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + const evidence = await buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + // A hatch external naming a non-reserved module is legal: the + // invariant rejects reserved specifiers, not the externals mechanism. + tools: { rspack: { externals: { fsevents: 'node-commonjs fsevents' } } }, + }); + const bundle = await readFile(join(root, 'dist', 'scripts', 'reserved-probe.mjs'), 'utf8'); + expect(bundle).toContain('inlined-runtime-shell'); + expect(bundle).toContain('generated-registry'); + expect(bundle).not.toMatch(/from\s*["']agent-bundle\//u); + // The scan tolerates a reserved specifier that is only mentioned as a + // string literal; only a live import fails the build. + expect(bundle).toContain('agent-bundle/mcp-entry'); + // Materialized generated modules never survive into the artifact and + // never count as authored source evidence. + await expect(readdir(join(root, 'dist', '.agent-bundle-virtual'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(evidence).toEqual([{ + path: 'scripts/reserved-probe.mjs', + sourceInputs: [join(root, 'src', 'entry.ts'), join(root, 'src', 'shell.ts')], + }]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); + +it('keeps materialized generated modules alive under a tools hatch that asks to clean the output root', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + // Dist cleaning runs when the build starts, after the generated wrapper + // and registry sources are written into that same tree, so an honored + // hatch would delete this build's own entry modules. Sibling entries + // already emitted into a shared staged root would go with them. + await buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + tools: { rsbuild: { output: { cleanDistPath: true } } }, + }); + const bundle = await readFile(join(root, 'dist', 'scripts', 'reserved-probe.mjs'), 'utf8'); + expect(bundle).toContain('inlined-runtime-shell'); + expect(bundle).toContain('generated-registry'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); + +it('rejects a tools hatch that externalizes a reserved specifier statically', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + await expect(buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + tools: { rspack: { externals: { 'agent-bundle/mcp-entry': 'module agent-bundle/mcp-entry' } } }, + })).rejects.toThrow(/must not externalize the reserved specifier "agent-bundle\/mcp-entry"/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); + +it('rejects a tools hatch that externalizes a reserved specifier through function externals', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + await expect(buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + tools: { + // Function externals cannot be inspected statically; the build-time + // guard intercepts them. The remap to a bare variable leaves no + // reserved text in the output, so only the guard can catch it. + rspack: (config) => { + config.externals = [ + ...(Array.isArray(config.externals) ? config.externals : config.externals === undefined ? [] : [config.externals]), + ({ request }, callback) => { + if (request === 'agent-bundle/mcp-apps') { + callback(undefined, 'var Registry'); + return; + } + callback(); + }, + ]; + }, + }, + })).rejects.toThrow(/must not externalize the reserved specifier "agent-bundle\/mcp-apps"/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); + +it('rejects a tools hatch alias that shadows a reserved specifier', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + await writeFile(join(root, 'src', 'evil.ts'), "export default 'shadowed-registry';\n"); + await expect(buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + // A plain (non-$) consumer alias for a reserved specifier would win + // over the framework's exact-match alias by insertion order. + tools: { rspack: { resolve: { alias: { 'agent-bundle/mcp-apps': join(root, 'src', 'evil.ts') } } } }, + })).rejects.toThrow(/must not alias the reserved specifier/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 4629cda13..7c7c2602e 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -5,7 +5,6 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { rspack } from '@rslib/core'; import { expect, it, rs } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; @@ -136,6 +135,7 @@ const importPublishedHook = async (wrapper: string) => }); it('does not share a persistent Rslib cache between generated executables', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-cache-output-')); const createOptions: unknown[] = []; const rslib = { build: async () => ({ @@ -150,29 +150,37 @@ it('does not share a persistent Rslib cache between generated executables', asyn inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - output: { asyncChunks: false, path: '/tmp/agent-bundle-rslib-cache-output' }, + name: 'agent-bundle-cache-probe', + output: { asyncChunks: false, path: outputRoot }, performance: { buildCache: false }, target: 'node', }], + environmentConfigs: { 'agent-bundle-cache-probe': { output: { cleanDistPath: false } } }, }, }), }; - await buildWithRslib({ - cwd: '/tmp', - entries: [{ - name: 'cache-probe', - outputRelativePath: 'hooks/cache-probe.mjs', - source: '/tmp/hook.ts', - sourceInputs: ['/tmp/hook.ts'], - }], - outputRoot: '/tmp/agent-bundle-rslib-cache-output', - }, { - createRslib: async (options) => { - createOptions.push(options); - return rslib as never; - }, - }); + try { + await mkdir(join(outputRoot, 'hooks'), { recursive: true }); + await writeFile(join(outputRoot, 'hooks', 'cache-probe.mjs'), 'export default undefined;\n'); + await buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'cache-probe', + outputRelativePath: 'hooks/cache-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + }], + outputRoot, + }, { + createRslib: async (options) => { + createOptions.push(options); + return rslib as never; + }, + }); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } const [{ config }] = createOptions as [{ readonly config: { @@ -182,7 +190,8 @@ it('does not share a persistent Rslib cache between generated executables', asyn expect(config.lib[0].performance).toEqual({ buildCache: false }); }); -it('closes the Rslib build result after building a virtual hook entry', async () => { +it('closes the Rslib build result and removes materialized generated modules after building a virtual hook entry', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-output-')); const close = rs.fn(async () => undefined); const buildResult = { close, @@ -193,60 +202,131 @@ it('closes the Rslib build result after building a virtual hook entry', async () }), }, }; + let materializedEntry: string | undefined; const rslib = { - build: async () => buildResult, + build: async () => { + // The generated wrapper entry must exist as a real on-disk module for + // the duration of the build (no virtual-module plugin involved). + materializedEntry = await readFile( + join(outputRoot, '.agent-bundle-virtual', 'close-probe-entry.mjs'), + 'utf8', + ); + return buildResult; + }, inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - output: { asyncChunks: false, path: '/tmp/agent-bundle-rslib-close-output' }, - plugins: [new rspack.experiments.VirtualModulesPlugin({})], + name: 'agent-bundle-close-probe', + output: { asyncChunks: false, path: outputRoot }, target: 'node', }], + environmentConfigs: { 'agent-bundle-close-probe': { output: { cleanDistPath: false } } }, }, }), }; - await buildWithRslib({ - cwd: '/tmp', - entries: [{ - name: 'close-probe', - outputRelativePath: 'hooks/close-probe.mjs', - source: '/tmp/hook.ts', - sourceInputs: ['/tmp/hook.ts'], - virtualSource: 'export default undefined;', - }], - outputRoot: '/tmp/agent-bundle-rslib-close-output', - }, { createRslib: async () => rslib as never }); - - expect(close).toHaveBeenCalledOnce(); + try { + await mkdir(join(outputRoot, 'hooks'), { recursive: true }); + await writeFile(join(outputRoot, 'hooks', 'close-probe.mjs'), 'export default undefined;\n'); + await buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'close-probe', + outputRelativePath: 'hooks/close-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + virtualSource: 'export default undefined;', + }], + outputRoot, + }, { createRslib: async () => rslib as never }); + + expect(close).toHaveBeenCalledOnce(); + expect(materializedEntry).toBe('export default undefined;'); + // The reserved directory never survives into artifact listing. + await expect(readdir(join(outputRoot, '.agent-bundle-virtual'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } +}); + +it('fails closed when an emitted bundle retains a residual reserved import', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-residual-output-')); + const rslib = { + build: async () => ({ + close: async () => undefined, + stats: { + toJson: () => ({ + assets: [{ name: 'hooks/residual-probe.mjs' }], + modules: [], + }), + }, + }), + inspectConfig: async () => ({ + origin: { + bundlerConfigs: [{ + name: 'agent-bundle-residual-probe', + output: { asyncChunks: false, path: outputRoot }, + target: 'node', + }], + environmentConfigs: { 'agent-bundle-residual-probe': { output: { cleanDistPath: false } } }, + }, + }), + }; + + try { + await mkdir(join(outputRoot, 'hooks'), { recursive: true }); + await writeFile( + join(outputRoot, 'hooks', 'residual-probe.mjs'), + 'import { runGeneratedStdioMcpEntry } from "agent-bundle/mcp-entry";\nawait runGeneratedStdioMcpEntry({});\n', + ); + await expect(buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'residual-probe', + outputRelativePath: 'hooks/residual-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + }], + outputRoot, + }, { createRslib: async () => rslib as never })).rejects.toThrow(/not self-contained/u); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } }); it('closes the Rslib build result when provenance stats are unavailable', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-error-output-')); const close = rs.fn(async () => undefined); const rslib = { build: async () => ({ close }), inspectConfig: async () => ({ origin: { bundlerConfigs: [{ - output: { asyncChunks: false, path: '/tmp/agent-bundle-rslib-close-error-output' }, + name: 'agent-bundle-close-error-probe', + output: { asyncChunks: false, path: outputRoot }, target: 'node', }], + environmentConfigs: { 'agent-bundle-close-error-probe': { output: { cleanDistPath: false } } }, }, }), }; - await expect(buildWithRslib({ - cwd: '/tmp', - entries: [{ - name: 'close-error-probe', - outputRelativePath: 'hooks/close-error-probe.mjs', - source: '/tmp/hook.ts', - sourceInputs: ['/tmp/hook.ts'], - }], - outputRoot: '/tmp/agent-bundle-rslib-close-error-output', - }, { createRslib: async () => rslib as never })).rejects.toThrow(/stats/i); - - expect(close).toHaveBeenCalledOnce(); + try { + await expect(buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'close-error-probe', + outputRelativePath: 'hooks/close-error-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + }], + outputRoot, + }, { createRslib: async () => rslib as never })).rejects.toThrow(/stats/i); + + expect(close).toHaveBeenCalledOnce(); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } }); it('normalizes a shorthand session-start hook into a frozen stable record', async () => { diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts index 55b290920..d02a80878 100644 --- a/packages/agent-bundle/tests/public-api.test.ts +++ b/packages/agent-bundle/tests/public-api.test.ts @@ -1,7 +1,8 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, mkdtemp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; @@ -212,15 +213,25 @@ it('keeps bundled config extension types in emitted root declarations', async () ); // The tools escape hatch types the Rsbuild environment-config surface, so // the declaration graph resolves the bundler packages exactly as - // installed consumers do (both are runtime dependencies of the package). + // installed consumers do. @rsbuild/core is a runtime dependency; the + // @rspack/core types it references resolve through it (Rslib guidance: + // never install @rspack/core directly), so the consumer fixture links + // the copy @rsbuild/core itself resolves. await symlink( join(agentBundleNodeModules, '@rsbuild'), join(consumerRoot, 'node_modules', '@rsbuild'), 'dir', ); + // realpath first: under pnpm the @rsbuild/core entry is a symlink into + // the virtual store, and plain-Node resolution walks the literal path + // (where @rspack/core is not visible) rather than the store. + const requireFromRsbuildCore = createRequire( + join(await realpath(join(agentBundleNodeModules, '@rsbuild', 'core')), 'package.json'), + ); + await mkdir(join(consumerRoot, 'node_modules', '@rspack')); await symlink( - join(agentBundleNodeModules, '@rspack'), - join(consumerRoot, 'node_modules', '@rspack'), + dirname(requireFromRsbuildCore.resolve('@rspack/core/package.json')), + join(consumerRoot, 'node_modules', '@rspack', 'core'), 'dir', ); // The bundler-inspection surface types the composed Rslib lib config, so diff --git a/packages/workbench/tests/runtime-inspector.test.ts b/packages/workbench/tests/runtime-inspector.test.ts index 72b259f4d..7f0b420b4 100644 --- a/packages/workbench/tests/runtime-inspector.test.ts +++ b/packages/workbench/tests/runtime-inspector.test.ts @@ -71,8 +71,9 @@ describe('Runtime inspector', () => { entry: { 'runtime-inspector-fixture': entry }, }; config.output = { ...config.output, distPath: { root: output } }; - const rsbuild = await createRsbuild({ rsbuildConfig: config }); - await rsbuild.build(); + const rsbuild = await createRsbuild({ config }); + const buildResult = await rsbuild.build(); + await buildResult.close(); const { server, url } = await startStaticServer(output); const browser = await chromium.launch({ channel: 'chrome' }); try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1327241c2..3b5806197 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,9 +205,6 @@ importers: '@rslint/core': specifier: 0.8.2 version: 0.8.2(jiti@2.7.0) - '@rspack/core': - specifier: 2.2.1 - version: 2.2.1(@swc/helpers@0.5.23) '@rstackjs/load-config': specifier: 0.1.2 version: 0.1.2(jiti@2.7.0)