diff --git a/.changeset/build-path-conformance.md b/.changeset/build-path-conformance.md index afa20e9d0..6452d0847 100644 --- a/.changeset/build-path-conformance.md +++ b/.changeset/build-path-conformance.md @@ -2,20 +2,21 @@ "agent-bundle": minor --- -Move the generated-executable build path onto fully documented bundler -surfaces (Rspack/Rslib/Rsbuild conformance audit). +Harden the generated-executable build path (Rspack/Rslib/Rsbuild +conformance audit). One deliberate experimental surface remains: +`rspack.experiments.VirtualModulesPlugin` serves generated module sources, +behind a feature check with an actionable diagnostic. - 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 + `main` process envelopes, `agent-bundle/mcp-apps` registries) now live at + dedicated, deterministic, guaranteed-nonexistent paths under the reserved + `.agent-bundle-virtual/` namespace — replacing the 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). + generated sources never reach the filesystem or 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 @@ -23,11 +24,10 @@ surfaces (Rspack/Rslib/Rsbuild conformance audit). 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. + scripts, MCP entries, hooks, and MCP Apps build sequentially into one + shared staged root, so a `tools.rsbuild.output.cleanDistPath: true` hatch + would delete sibling outputs already emitted there. 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 diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 2693d0ab3..0e2fcc901 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -1,12 +1,13 @@ // 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 { createRslib, mergeRslibConfig, rspack, 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 { readFile, realpath } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { isErrno } from '../core/errors.ts'; +import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig } from '../core/types.ts'; import { mcpEntryRuntimeSpecifier } from './entry-shell.ts'; import { collectBundledOutputEvidence, type BundledOutputEvidence } from './provenance.ts'; @@ -47,14 +48,36 @@ interface RslibDependencies { } /** - * 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. + * The reserved namespace (under each build's output root) whose paths + * identify generated module sources — wrapper entries and registry modules. + * Nothing ever writes these paths: they are guaranteed-nonexistent module + * ids served from memory by {@link virtualModulesPluginConstructor}, chosen + * to be deterministic for `inspect --bundler` and collision-safe across + * entries. The namespace stays excluded from authored-source provenance. */ const generatedModulesDirname = '.agent-bundle-virtual'; +/** + * Generated sources ride Rspack's `experiments.VirtualModulesPlugin` instead + * of throwaway files on disk — an accepted design decision: the experimental + * surface is the cost of never touching the artifact tree with build-time + * scratch files. This narrow feature check turns an upstream rename or + * removal into an actionable diagnostic instead of an opaque resolution + * failure deep inside a build. + */ +const virtualModulesPluginConstructor = (): typeof rspack.experiments.VirtualModulesPlugin => { + const constructor = (rspack as { readonly experiments?: { readonly VirtualModulesPlugin?: unknown } }) + .experiments?.VirtualModulesPlugin; + if (typeof constructor !== 'function') { + throw new Error( + 'The Rspack engine nested in @rslib/core no longer exposes experiments.VirtualModulesPlugin, ' + + 'which agent-bundle uses to serve generated wrapper and registry modules. ' + + 'Pin @rslib/core to a version whose Rspack ships the plugin, or update agent-bundle.', + ); + } + return constructor as typeof rspack.experiments.VirtualModulesPlugin; +}; + /** * 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 @@ -72,12 +95,21 @@ const asRslibRspackHatch = ( 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. +// join (not resolve) so a tokenized output root (`/`) stays +// a token instead of resolving against the cwd. const generatedEntryModulePath = (outputRoot: string, entry: RslibEntry): string => join(outputRoot, generatedModulesDirname, `${entry.name}-entry.mjs`); -const materializedVirtualModules = ( +/** The import requests of one named entry in a lowered Rspack entry record. */ +const entryImportsOf = (entryRecord: unknown, name: string): readonly string[] => { + const description = isRecord(entryRecord) ? entryRecord[name] : undefined; + const imports = isRecord(description) ? description.import : description; + if (typeof imports === 'string') return [imports]; + if (Array.isArray(imports)) return imports.filter((item): item is string => typeof item === 'string'); + return []; +}; + +const virtualRegistryModules = ( outputRoot: string, entry: RslibEntry, ): readonly { readonly name: string; readonly path: string; readonly source: string }[] => @@ -86,17 +118,6 @@ const materializedVirtualModules = ( 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 @@ -276,9 +297,11 @@ const declaredDependencyRoots = async (cwd: string): Promise }; interface InspectedBundlerConfig { + readonly entry?: unknown; readonly externals?: unknown; readonly name?: string; readonly output?: { readonly asyncChunks?: boolean; readonly path?: string }; + readonly plugins?: readonly unknown[]; readonly resolve?: { readonly alias?: unknown }; readonly target?: false | string | readonly string[]; } @@ -312,9 +335,10 @@ const assertExecutableConfig = ( 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. + // Scripts, MCP entries, hooks, and MCP Apps build sequentially into one + // shared staged root, so an environment that cleans its dist path would + // delete sibling outputs already emitted there; 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.'); } @@ -329,10 +353,23 @@ const assertExecutableConfig = ( if (config.output?.asyncChunks !== false || config.output.path !== outputRoot || !target.some((value) => value === 'node')) { throw new Error('Rslib resolved an invalid generated executable configuration.'); } + // The generated environment must retain the virtual-module source: a + // resolved config without the plugin instance would resolve the + // guaranteed-nonexistent generated paths against the real filesystem. + const registryModules = virtualRegistryModules(outputRoot, entry); + if (entry.virtualSource !== undefined || registryModules.length > 0) { + const constructor = virtualModulesPluginConstructor(); + if (config.plugins?.some((plugin) => plugin instanceof constructor) !== true) { + throw new Error('Rslib resolved a generated executable environment without its virtual modules.'); + } + } + if (entry.virtualSource !== undefined + && !entryImportsOf(config.entry, entry.name).includes(generatedEntryModulePath(outputRoot, entry))) { + throw new Error('Rslib resolved a generated executable environment without its generated wrapper entry.'); + } const expectedAliases = { ...entry.aliases, - ...Object.fromEntries(materializedVirtualModules(outputRoot, entry) - .map((module) => [module.name, module.path])), + ...Object.fromEntries(registryModules.map((module) => [module.name, module.path])), }; const alias = aliasRecordOf(config); const frameworkAliasKeys = new Set(Object.keys(expectedAliases).map((name) => `${name}$`)); @@ -374,7 +411,15 @@ export const composeEntryLibConfig = ( ): LibConfig => { const libId = entryLibId(entry); const virtualSource = entry.virtualSource; - const virtualModules = materializedVirtualModules(options.outputRoot, entry); + const virtualModules = virtualRegistryModules(options.outputRoot, entry); + // Every generated module this entry serves virtually during its build: + // the wrapper entry (when present) plus the registry modules. + const generatedModules = [ + ...(virtualSource === undefined + ? [] + : [{ path: generatedEntryModulePath(options.outputRoot, entry), source: virtualSource }]), + ...virtualModules, + ]; const aliases = entry.aliases ?? {}; const reserved = reservedSpecifiers(entry); const frameworkAliasKeys = new Set([ @@ -406,6 +451,33 @@ export const composeEntryLibConfig = ( }, }; } + if (generatedModules.length > 0) { + // Added after the hatch mutator (this hook is merged last), so a + // consumer cannot strip the generated sources out of the compiler. + const VirtualModulesPlugin = virtualModulesPluginConstructor(); + config.plugins = [ + ...(config.plugins ?? []), + new VirtualModulesPlugin(Object.fromEntries(generatedModules.map((module) => [module.path, module.source]))), + ]; + } + if (virtualSource !== undefined) { + // Rslib validates `source.entry` against the real filesystem before + // Rspack exists, so the profile keys the entry on the authored program + // and this hook redirects the lowered Rspack entry to the generated + // wrapper's guaranteed-nonexistent virtual path, which the plugin + // above serves from memory. Rspack resolves entries through the + // plugin-patched input filesystem, so no real path is ever shadowed. + const lowered = config.entry; + if (!isRecord(lowered)) { + throw new Error('Rslib lowered a generated executable without a keyed entry record.'); + } + const description = lowered[entry.name]; + const wrapperImport = [generatedEntryModulePath(options.outputRoot, entry)]; + config.entry = { + ...lowered, + [entry.name]: isRecord(description) ? { ...description, import: wrapperImport } : wrapperImport, + } as typeof config.entry; + } const violation = reservedExternalsViolation(config.externals, reserved); if (violation !== undefined) throw reservedExternalError(violation); if (config.externals !== undefined) { @@ -446,8 +518,12 @@ export const composeEntryLibConfig = ( target: 'node', }, source: { + // Always the authored program, even when a generated wrapper is the + // real compilation root: Rslib checks that every entry exists on disk, + // and `enforceInvariants` redirects the lowered Rspack entry to the + // wrapper's virtual path. entry: { - [entry.name]: virtualSource === undefined ? entry.source : generatedEntryModulePath(options.outputRoot, entry), + [entry.name]: entry.source, }, ...(entry.tsconfigPath === undefined ? {} : { tsconfigPath: entry.tsconfigPath }), }, @@ -461,11 +537,9 @@ export const composeEntryLibConfig = ( ? 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. + // 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 } }] }, ); const lib = merged.lib?.[0]; @@ -491,34 +565,23 @@ export const buildWithRslib = async (options: { } const dependencyRoots = await declaredDependencyRoots(options.cwd); - // 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)); + 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); let result: Awaited> | undefined; try { - 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) { @@ -534,7 +597,9 @@ export const buildWithRslib = async (options: { sourceInputs: entry.sourceInputs, })), ignoredSourcePaths: [ - generatedModulesRoot, + // Generated wrapper/registry modules are virtual, but they still + // surface in stats as modules under this reserved namespace. + resolve(options.outputRoot, generatedModulesDirname), ...(options.ignoredSourcePaths ?? []), ...dependencyRoots, ], @@ -544,10 +609,6 @@ export const buildWithRslib = async (options: { await assertNoResidualReservedImports(options.entries, options.outputRoot); return evidence; } finally { - try { - await result?.close(); - } finally { - await rm(generatedModulesRoot, { force: true, recursive: true }); - } + await result?.close(); } }; diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 25ee2179b..241430a51 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -1078,9 +1078,10 @@ it('restores the existing artifact when publication fails after backup', async ( }); /** - * 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. + * A minimal project exercising every generated-source mechanism of one + * executable: a virtual wrapper entry (compiled from a guaranteed-nonexistent + * path under the reserved `.agent-bundle-virtual/` namespace), an alias onto + * an on-disk runtime module, and a virtual generated registry module. */ const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; readonly root: string }> => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-reserved-specifiers-')); @@ -1095,7 +1096,7 @@ const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; // 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);', + 'export const main = () => { console.log(marker, registry, mentioned); };', '', ].join('\n')); return { @@ -1106,12 +1107,21 @@ const reservedSpecifierProject = async (): Promise<{ readonly entry: RslibEntry; source: join(sourceRoot, 'entry.ts'), sourceInputs: [join(sourceRoot, 'entry.ts')], virtualModules: [{ name: 'agent-bundle/mcp-apps', source: "export default 'generated-registry';\n" }], + virtualSource: [ + `import { main } from ${JSON.stringify(join(sourceRoot, 'entry.ts'))};`, + // A marker only the generated wrapper contains: its presence in the + // emitted bundle proves the wrapper (not the authored program) was + // the compilation root. + "console.log('generated-wrapper-marker');", + 'main();', + '', + ].join('\n'), }, root, }; }; -it('inlines reserved specifiers through exact-match aliases and materialized generated modules', async () => { +it('inlines reserved specifiers through exact-match aliases and virtual generated modules', async () => { const { entry, root } = await reservedSpecifierProject(); try { const evidence = await buildWithRslib({ @@ -1125,12 +1135,14 @@ it('inlines reserved specifiers through exact-match aliases and materialized gen 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).toContain('generated-wrapper-marker'); 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. + // The wrapper entry and registry module were served from memory at + // guaranteed-nonexistent paths: the reserved namespace never reaches the + // filesystem and never counts 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', @@ -1141,13 +1153,14 @@ it('inlines reserved specifiers through exact-match aliases and materialized gen } }, 20_000); -it('keeps materialized generated modules alive under a tools hatch that asks to clean the output root', async () => { +it('keeps sibling staged outputs 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. + // Scripts, MCP entries, hooks, and MCP Apps build sequentially into one + // shared staged root, so an honored cleanDistPath hatch would delete + // sibling outputs already emitted there. + await mkdir(join(root, 'dist'), { recursive: true }); + await writeFile(join(root, 'dist', 'sibling.mjs'), 'export default "already-emitted-sibling";\n'); await buildWithRslib({ cwd: root, entries: [entry], @@ -1157,6 +1170,33 @@ it('keeps materialized generated modules alive under a tools hatch that asks to const bundle = await readFile(join(root, 'dist', 'scripts', 'reserved-probe.mjs'), 'utf8'); expect(bundle).toContain('inlined-runtime-shell'); expect(bundle).toContain('generated-registry'); + await expect(readFile(join(root, 'dist', 'sibling.mjs'), 'utf8')) + .resolves.toContain('already-emitted-sibling'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 20_000); + +it('overrides a tools hatch that strips plugins and repoints the entry away from the generated wrapper', async () => { + const { entry, root } = await reservedSpecifierProject(); + try { + // The hatch mutator runs before the framework invariant hook, so it + // cannot strip the VirtualModulesPlugin (added afterwards) or keep the + // entry repointed at the authored program (redirected afterwards). + await buildWithRslib({ + cwd: root, + entries: [entry], + outputRoot: join(root, 'dist'), + tools: { + rspack: (config) => { + config.plugins = []; + config.entry = { 'reserved-probe': [join(root, 'src', 'entry.ts')] }; + }, + }, + }); + const bundle = await readFile(join(root, 'dist', 'scripts', 'reserved-probe.mjs'), 'utf8'); + expect(bundle).toContain('generated-wrapper-marker'); + expect(bundle).toContain('generated-registry'); } finally { await rm(root, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 7c7c2602e..11f5151e0 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -6,6 +6,7 @@ import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { expect, it, rs } from '@rstest/core'; +import { rspack } from '@rslib/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import { nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; @@ -190,8 +191,9 @@ 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 and removes materialized generated modules after building a virtual hook entry', async () => { +it('closes the Rslib build result and serves the generated wrapper entry virtually without touching disk', async () => { const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-close-output-')); + const virtualEntryPath = join(outputRoot, '.agent-bundle-virtual', 'close-probe-entry.mjs'); const close = rs.fn(async () => undefined); const buildResult = { close, @@ -202,22 +204,23 @@ it('closes the Rslib build result and removes materialized generated modules aft }), }, }; - let materializedEntry: string | undefined; + let reservedNamespaceDuringBuild: unknown; + const createOptions: unknown[] = []; const rslib = { 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', - ); + // The generated wrapper entry is served from memory: its reserved + // namespace must not exist on disk even while the build is running. + reservedNamespaceDuringBuild = await readdir(join(outputRoot, '.agent-bundle-virtual')) + .then(() => undefined, (error: unknown) => error); return buildResult; }, inspectConfig: async () => ({ origin: { bundlerConfigs: [{ + entry: { 'close-probe': [virtualEntryPath] }, name: 'agent-bundle-close-probe', output: { asyncChunks: false, path: outputRoot }, + plugins: [new rspack.experiments.VirtualModulesPlugin({})], target: 'node', }], environmentConfigs: { 'agent-bundle-close-probe': { output: { cleanDistPath: false } } }, @@ -238,12 +241,89 @@ it('closes the Rslib build result and removes materialized generated modules aft virtualSource: 'export default undefined;', }], outputRoot, - }, { createRslib: async () => rslib as never }); + }, { + createRslib: async (options) => { + createOptions.push(options); + return rslib as never; + }, + }); expect(close).toHaveBeenCalledOnce(); - expect(materializedEntry).toBe('export default undefined;'); - // The reserved directory never survives into artifact listing. + expect(reservedNamespaceDuringBuild).toMatchObject({ code: 'ENOENT' }); await expect(readdir(join(outputRoot, '.agent-bundle-virtual'))).rejects.toMatchObject({ code: 'ENOENT' }); + + // The composed profile keys the entry on the authored program (Rslib + // checks entry existence on the real filesystem), and the invariant hook + // redirects the lowered Rspack entry to the guaranteed-nonexistent + // virtual path it registers with VirtualModulesPlugin. + const [{ config }] = createOptions as [{ + readonly config: { + readonly lib: readonly [{ + readonly source: { readonly entry: Readonly> }; + readonly tools?: { readonly rspack?: unknown }; + }]; + }; + }]; + expect(config.lib[0].source.entry).toEqual({ 'close-probe': '/tmp/hook.ts' }); + const hooksChain = config.lib[0].tools?.rspack; + const mutators = (Array.isArray(hooksChain) ? hooksChain : [hooksChain]) + .filter((mutator): mutator is (value: object) => object => typeof mutator === 'function'); + expect(mutators.length).toBeGreaterThan(0); + const resolved: { entry?: unknown; plugins?: readonly unknown[] } = { + entry: { 'close-probe': ['/tmp/hook.ts'] }, + }; + for (const mutator of mutators) mutator(resolved); + expect(resolved.entry).toEqual({ 'close-probe': [virtualEntryPath] }); + const virtualPlugins = (resolved.plugins ?? []) + .filter((plugin) => plugin instanceof rspack.experiments.VirtualModulesPlugin); + expect(virtualPlugins).toHaveLength(1); + } finally { + await rm(outputRoot, { force: true, recursive: true }); + } +}); + +it('fails closed when the resolved environment lost its virtual modules or wrapper entry', async () => { + const outputRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-rslib-lost-virtual-')); + const virtualEntryPath = join(outputRoot, '.agent-bundle-virtual', 'lost-probe-entry.mjs'); + const rslibFor = (bundlerConfig: Record) => ({ + build: async () => { + throw new Error('inspection must fail before the build starts'); + }, + inspectConfig: async () => ({ + origin: { + bundlerConfigs: [{ + name: 'agent-bundle-lost-probe', + output: { asyncChunks: false, path: outputRoot }, + target: 'node', + ...bundlerConfig, + }], + environmentConfigs: { 'agent-bundle-lost-probe': { output: { cleanDistPath: false } } }, + }, + }), + }); + const buildLostProbe = async (bundlerConfig: Record) => buildWithRslib({ + cwd: '/tmp', + entries: [{ + name: 'lost-probe', + outputRelativePath: 'hooks/lost-probe.mjs', + source: '/tmp/hook.ts', + sourceInputs: ['/tmp/hook.ts'], + virtualSource: 'export default undefined;', + }], + outputRoot, + }, { createRslib: async () => rslibFor(bundlerConfig) as never }); + + try { + // A resolved config without the plugin would resolve the + // guaranteed-nonexistent generated paths against the real filesystem. + await expect(buildLostProbe({ entry: { 'lost-probe': [virtualEntryPath] } })) + .rejects.toThrow(/without its virtual modules/u); + // A resolved config still keyed on the authored program would compile + // without the generated wrapper. + await expect(buildLostProbe({ + entry: { 'lost-probe': ['/tmp/hook.ts'] }, + plugins: [new rspack.experiments.VirtualModulesPlugin({})], + })).rejects.toThrow(/without its generated wrapper entry/u); } finally { await rm(outputRoot, { force: true, recursive: true }); }