From 1a658ffeeea5fd6a9867dae9d23919b460199d42 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:20:32 +0000 Subject: [PATCH 01/10] feat(inspect): render the lowered Rspack config in inspect --bundler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-bundle inspect --bundler` rendered the composed Rslib/Rsbuild author config — the framework profile with the `tools` hatch merged over it — not what the compiler receives. Each entry's `config` is now the lowered Rspack configuration from `inspection.origin.bundlerConfigs`, produced by the build's own lowering step and stopped where the build would start compiling. - rslib.ts: extract `lowerEntries` (create instance, compose every entry, `inspectProductionConfig`, `assertExecutableConfig`) and share it between `buildRslibSurfaces` and the new `inspectRslibEntries`; generalize `inspectProductionConfig` (NODE_ENV restore) to any `inspectConfig` owner. - mcp-apps.ts: extract `lowerViews` (Rsbuild instance, compile-time plugins, production inspection, `assertResolvedViewConfig`) and share it between `compileMcpApps` and the new `inspectMcpAppsConfig`. - inspect-bundler.ts: plan every Rslib output (scripts, routed CLI bins, MCP entries and flight workers, hook wrappers, package bin/lib) and lower each run through the shared step; render configs JSON-safe (`[function ]`, `[object ]`, `[regexp /src/]`) with the artifact root folded to `` and the synthesized dts tsconfig to `` so the output is deterministic per project. - api.ts: `AB7001` for a refused bundler inspection carries the lowering's refusal message. - Tests: inspect-bundler.test.ts asserts the lowered shape (resolved aliases, externals, framework plugins, output.path tokens, determinism, refusal); cli.test.ts asserts the CLI JSON and human output against the same shape. - Docs: entry-conventions, diagnostics (AB7001), framework-mode, READMEs, and the en/zh CLI reference, package-entries, and distribution pages. --- README.md | 2 +- docs/diagnostics.md | 2 +- docs/entry-conventions.md | 54 ++- docs/framework-mode.md | 3 +- packages/agent-bundle/README.md | 2 +- packages/agent-bundle/src/api.ts | 6 +- .../agent-bundle/src/build/inspect-bundler.ts | 330 +++++++++--------- packages/agent-bundle/src/build/mcp-apps.ts | 80 +++-- packages/agent-bundle/src/build/rslib.ts | 115 ++++-- packages/agent-bundle/tests/cli.test.ts | 32 +- .../tests/inspect-bundler.test.ts | 241 +++++++++---- .../en/guide/authoring/package-entries.mdx | 12 +- website/docs/en/guide/distribution/index.mdx | 2 +- website/docs/en/reference/cli.mdx | 2 +- .../zh/guide/authoring/package-entries.mdx | 9 +- website/docs/zh/guide/distribution/index.mdx | 2 +- website/docs/zh/reference/cli.mdx | 2 +- 17 files changed, 568 insertions(+), 328 deletions(-) diff --git a/README.md b/README.md index 800c4e6d8..2762b8ee6 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ The same config also owns the npm package build — no second bundler config, bi - `build` — validate the project and write the plugin root (plus the `bin`/`lib` package build when declared) - `validate` — check project source, or a built artifact with `--artifact ` -- `inspect` — show the normalized configuration and each selected host's projection plan; `--bundler` dumps the synthesized bundler configs (post-`tools`-hatch merge) +- `inspect` — show the normalized configuration and each selected host's projection plan; `--bundler` dumps the lowered Rspack config of every compiled output (post-`tools`-hatch merge, as Rslib/Rsbuild hand it to the compiler) - `dev` — serve the local development workbench and rebuild the `dist/` package build when its inputs change; `--install-host ` installs a development variant whose stable `dev proxy` MCP command hot-swaps epochs behind the host's open connection and re-syncs hooks and Skills on every adopted rebuild (see [Framework mode › Live development into hosts](docs/framework-mode.md#live-development-into-hosts)) - `mcp list` / `mcp invoke` / `mcp run` — list, invoke, or run an artifact's MCP servers locally - `hooks list` / `hooks simulate` — inspect and simulate generated hooks diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5a7ec4d39..9b8dce366 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1871,7 +1871,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | | `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | -| `AB7001` | error | Install/uninstall: the bundle identity is unreadable — no host manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed); a manifest that is not a JSON object or lacks a nonempty required key; a Cursor plugin name that is not a safe local name; a Claude or Codex bundle with no marketplace identity. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at a built bundle whose host manifest carries the fields the message names. Preparation: fix normalized project configuration and source references, then inspect again. | +| `AB7001` | error | Install/uninstall: the bundle identity is unreadable — no host manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed); a manifest that is not a JSON object or lacks a nonempty required key; a Cursor plugin name that is not a safe local name; a Claude or Codex bundle with no marketplace identity. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — the lowering ran the build's own Rslib/Rsbuild invariant assertions and the reason names the `tools` value the build would refuse (a reserved `agent-bundle/*` alias, a framework runtime module in `externals`). | Install: point `--from` at a built bundle whose host manifest carries the fields the message names. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: remove the refused `tools` value the reason names. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index f92b1ba4c..6932d48ef 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1404,24 +1404,42 @@ kind whose row is not `supported`. The full matrix is in agent-bundle inspect --bundler [--target ] [--json] ``` -Dumps the synthesized bundler configuration for every output the build -composes — artifact scripts, MCP entries, hook wrappers, the composite root's -MCP Apps Rsbuild config, and the `dist/` package build — exactly as the build -lowers it: in production mode whatever `NODE_ENV` says, the framework profile -with the consumer `tools` hatch merged over it and the invariant hook appended -last (functions render as `[function ]`). Entries the framework wraps also carry the generated -wrapper module source (`generatedEntry`). The composition comes from the same -functions the build uses, so the dump cannot drift from what compiles. - -Nothing is redacted (this is a local debugging surface), but two build-time -values are replaced with stable tokens so output is deterministic for one -project: the composite artifact root (chosen per build) appears as -``, and the synthesized declaration tsconfig (a temporary -file generated per package build) appears as ``. The -package build's output root appears as its published destination, `dist`, -although each real build stages outputs before publishing them atomically. -Resolved post-bundler internals stay Rslib's domain; this surfaces -agent-bundle's own composition, which is where the `tools` hatch lands. +Dumps the **lowered Rspack configuration** of every output the build +compiles — artifact scripts, MCP entries, hook wrappers, the routed CLI bin, +each MCP App view, and the `dist/` package build's `bin` and `lib` entries — +one entry per compiler. The framework profile, the consumer `tools` hatch, +and the invariant layer are composed exactly as the build composes them, then +handed to the build's own engine — Rslib for executables, Rsbuild for MCP App +views — and stopped where the build would start compiling. What prints is +what the compiler receives: resolved `resolve.alias` entries (the +`agent-bundle/*` runtime modules and the project-rooted +`.agent-bundle-virtual/` generated modules beside the consumer's own +aliases), the `externals` list, the framework plugins +(`[object VirtualModulesPlugin]`, `[object ArtifactDependencyAuditPlugin]`), +`output.path`, module rules, and every default the engine fills in. The +lowering runs in production mode whatever `NODE_ENV` says and restores it +afterwards, and it runs the build's invariant assertions: a `tools` value the +build would refuse (a reserved alias, an `externals` entry naming a framework +runtime module) makes the inspection `invalid` with an `AB7001` diagnostic +carrying the refusal, instead of a config that never compiles. Entries the +framework wraps also carry the generated wrapper module source +(`generatedEntry`). + +The JSON rendering keeps the config's shape without dropping values JSON +cannot carry: functions render as `[function ]` (Rslib lowers each +entry's file name to `[function jsFilename]`), plugin instances as +`[object ]`, regular expressions as `[regexp //]`. + +Nothing is redacted (this is a local debugging surface; the lowered configs +carry absolute paths of the project and of agent-bundle's installed +toolchain), but two build-time values are replaced with stable tokens so the +output is deterministic for one project: the composite artifact root (chosen +per build) appears as `` — as `output.path` and inside any path +beneath it — and the synthesized declaration tsconfig (a temporary file +generated per package build) appears as ``. The +package build's `output.path` is its published destination, +`/dist`, although each real build stages outputs before +publishing them atomically. ## Dev-watch of the package build diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 65df08e6e..b2a75484d 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -564,7 +564,8 @@ Every synthesized bundler config — both stages plus the `dist/` package build `tools.rsbuild` fragment, then the `tools.rspack` hatch, then the framework invariant layer that no hatch value can override (`src/build/compose-layers.ts`; see the `tools` section of the configuration -reference). `agent-bundle inspect --bundler` prints the result. +reference). `agent-bundle inspect --bundler` prints the lowered Rspack +configuration each engine receives from that composition. Builds are byte-reproducible: two builds of one unchanged source tree emit identical artifacts (same manifest, same digests, same bytes) regardless of diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 83ea964a5..66dd47f88 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -140,7 +140,7 @@ manifests at files inside those payloads without compiling them. Payload files c | `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect the normalized model and each selected host projection's plan from source, with per-host component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | -| `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. | +| `agent-bundle inspect --bundler` | Dump the lowered Rspack config (post-`tools`-hatch merge, as Rslib/Rsbuild hand it to the compiler) for every generated output. | | `agent-bundle mcp list` / `mcp invoke` | List or invoke one MCP tool from an artifact. | | `agent-bundle mcp run` | Run one built stdio MCP server in the foreground, resolving its hashed entry, loading the project-root `.env` set (`--env-file`/`--no-env` to override), and expanding env state anchors to the project root (`--plugin-root` to override). Environment precedence: manifest env < `.env` files < operator `process.env`. | | `agent-bundle hooks list` / `hooks simulate` | List generated hooks, or run one emitted wrapper. | diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index b17914618..48bd80496 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1152,12 +1152,14 @@ export const inspect = async (options: InspectOptions): Promise = projectRoot: prepared.root, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); - } catch { + } catch (error) { + // The lowering runs the build's own invariant assertions, so the + // refusal names the hatch value the build would refuse. return invalidInspection(freezeDiagnostics([ ...prepared.diagnostics, projectDiagnostic( 'AB7001', - 'Unable to compose the bundler inspection.', + `Unable to compose the bundler inspection: ${error instanceof Error ? error.message : String(error)}`, { sourcePath: prepared.configPath }, ), ])); diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 3415f47e8..9208ae566 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -1,3 +1,5 @@ +import { resolve } from 'node:path'; + import { eventIpcRuntimeSpecifier, eventProjectRuntimeSpecifier, @@ -26,46 +28,55 @@ import { launchEnvRuntimeSpecifier, operatorEnvLayerVirtualModule } from './laun import { cliBinRslibEntries, planCompiledCliBins } from './cli-bins.ts'; import type { CompositePlan } from './compose.ts'; import { eventRuntimeHosting, eventRuntimeModulePath, planCompiledMcpEntries, selectedServerHosts } from './entries.ts'; -import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; +import { inspectMcpAppsConfig, planCompiledMcpApps } from './mcp-apps.ts'; import { projectMeta } from './meta.ts'; import { planPackageEntries } from './package-build.ts'; -import { composeEntryLibConfig, type RslibEntry } from './rslib.ts'; +import { inspectRslibEntries, type RslibEntry } from './rslib.ts'; import { deepFreeze } from '../core/freeze.ts'; import type { AgentBundleMeta } from '../meta.ts'; /** - * `agent-bundle inspect --bundler` (RFC #50 §3.4): surfaces the internal - * Rslib/Rsbuild configurations the build composes — the framework profile - * with the consumer `tools` escape hatch merged over it and the invariant - * hook appended last — for every synthesized output. The composition comes - * from the same functions the build lowers (`composeEntryLibConfig`, - * `composeMcpAppsRsbuildConfig`), so the inspection can never drift from - * what actually compiles. + * `agent-bundle inspect --bundler` (RFC #50 §3.4): surfaces the lowered + * Rspack configuration of every output the build compiles — the framework + * profile with the consumer `tools` escape hatch merged over it and the + * invariant hook applied last, resolved by Rslib (executables) or Rsbuild + * (MCP App views) into what the compiler receives. The lowering is the + * build's own step (`inspectRslibEntries`, `inspectMcpAppsConfig`), run in + * production mode whatever `NODE_ENV` says and stopped where the build would + * start compiling, so the inspection can never drift from what actually + * compiles; a hatch value the invariants refuse fails the inspection the + * way it would fail the build. * * Two build-time-only values are replaced with stable tokens so the output * is deterministic for one project: the composite artifact root (chosen by * `build --output` and staged per build) appears as ``, and the - * synthesized declaration tsconfig (a temporary file the package - * build generates under `node_modules`) appears as - * ``. Nothing else is redacted; this is a local - * debugging surface. The generated-module namespace + * synthesized declaration tsconfig (a temporary file the package build + * generates under `node_modules`) appears as ``. + * Nothing else is redacted; this is a local debugging surface, and the + * lowered configs carry absolute paths of the project and of agent-bundle's + * installed toolchain. The generated-module namespace * (`/.agent-bundle-virtual/...`) appears exactly as the build * composes it: it derives from the project root, not from the output root. */ export interface BundlerInspectionEntry { + /** The engine that lowered this entry: Rslib for executables, Rsbuild for MCP App views. */ readonly bundler: 'rsbuild' | 'rslib'; - /** The composed config, JSON-rendered: functions appear as `[function ]`. */ + /** + * The lowered Rspack configuration, JSON-rendered: functions appear as + * `[function ]`, class instances (plugins) as `[object ]`, + * regular expressions as `[regexp ]`. + */ readonly config: unknown; /** The generated wrapper entry module, when the framework provides one. */ readonly generatedEntry?: string; - readonly kind: 'bin' | 'hook' | 'lib' | 'mcp-apps' | 'mcp-entry' | 'script'; + readonly kind: 'bin' | 'hook' | 'lib' | 'mcp-app' | 'mcp-entry' | 'script'; readonly name: string; /** POSIX output path relative to the artifact root (artifact surfaces) or project root (package build). */ readonly outputPath: string; - /** The authored entry module (absent for the MCP Apps config). */ - readonly source?: string; + /** The authored entry module. */ + readonly source: string; /** The composite identity of the selected projections; absent for package-build entries. */ readonly target?: string; } @@ -80,14 +91,27 @@ const artifactOutputToken = ''; const isPlainObject: (value: object) => boolean = isPlainRecord; +/** Absolute build-time paths and the stable tokens that stand for them in the rendered configs. */ +type PathTokens = readonly (readonly [absolute: string, token: string])[]; + +const tokenizePath = (value: string, tokens: PathTokens): string => { + for (const [absolute, token] of tokens) { + if (value === absolute) return token; + if (value.startsWith(`${absolute}/`)) return `${token}${value.slice(absolute.length)}`; + } + return value; +}; + /** - * Renders a composed bundler config as JSON-safe data without dropping the - * shape: functions (consumer `tools.rspack` mutators, the framework - * invariant hook) become `[function ]`, class instances become - * `[object ]`. + * Renders a lowered Rspack config as JSON-safe data without dropping the + * shape: functions (function-form externals, hatch callbacks) become + * `[function ]`, class instances (plugins, loaders' option objects) + * become `[object ]`, regular expressions (module rules, + * externals) become `[regexp ]`. */ -const renderConfigValue = (value: unknown, ancestors = new Set()): unknown => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; +const renderConfigValue = (value: unknown, tokens: PathTokens, ancestors = new Set()): unknown => { + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'string') return tokenizePath(value, tokens); if (typeof value === 'number') return Number.isFinite(value) ? value : String(value); if (typeof value === 'function') return `[function ${value.name.length === 0 ? 'anonymous' : value.name}]`; if (typeof value !== 'object') return String(value); @@ -95,61 +119,41 @@ const renderConfigValue = (value: unknown, ancestors = new Set()): unkno ancestors.add(value); try { + if (value instanceof RegExp) return `[regexp ${String(value)}]`; if (Array.isArray(value)) { - return value.map((item) => renderConfigValue(item, ancestors)); + return value.map((item) => renderConfigValue(item, tokens, ancestors)); } if (!isPlainObject(value)) { - return `[object ${value.constructor?.name ?? 'unknown'}]`; + const constructor = value.constructor?.name; + return `[object ${constructor === undefined || constructor.length === 0 ? 'anonymous' : constructor}]`; } return Object.fromEntries(Object.entries(value) .filter(([, item]) => item !== undefined) - .map(([key, item]) => [key, renderConfigValue(item, ancestors)])); + .map(([key, item]) => [key, renderConfigValue(item, tokens, ancestors)])); } finally { ancestors.delete(value); } }; -const rslibInspectionEntry = (options: { +/** One Rslib-compiled output the inspection lowers, before its config is known. */ +interface PlannedRslibInspection { readonly entry: RslibEntry; - readonly kind: BundlerInspectionEntry['kind']; - readonly meta: AgentBundleMeta; + readonly kind: Exclude; readonly name: string; readonly outputPath: string; - readonly outputRoot: string; - readonly projectRoot: string; - readonly source: string; readonly target?: string; - readonly tools?: AgentBundleToolsConfig; -}): BundlerInspectionEntry => Object.freeze({ - bundler: 'rslib', - config: renderConfigValue(composeEntryLibConfig(options.entry, { - cwd: options.projectRoot, - meta: options.meta, - outputRoot: options.outputRoot, - ...(options.tools === undefined ? {} : { tools: options.tools }), - })), - ...(options.entry.virtualSource === undefined ? {} : { generatedEntry: options.entry.virtualSource }), - kind: options.kind, - name: options.name, - outputPath: options.outputPath, - source: options.source, - ...(options.target === undefined ? {} : { target: options.target }), -}); +} const scriptEntries = async ( model: NormalizedPlugin, - projectRoot: string, composite: CompositeSelection, - tools: AgentBundleToolsConfig | undefined, -): Promise => { - const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken; +): Promise => { const target = composite.identity; const scripts = model.scripts.filter((script) => script.mode === 'bundle' && script.targets.some((candidate) => composite.selected.includes(candidate))); return Promise.all(scripts.map(async (script) => { const exports = await scanEntryExports(script.source); - return rslibInspectionEntry({ + return { entry: { name: script.name, outputRelativePath: `scripts/${script.name}.mjs`, @@ -166,56 +170,36 @@ const scriptEntries = async ( } : {}), }, - kind: 'script', - meta, + kind: 'script' as const, name: script.name, outputPath: `scripts/${script.name}.mjs`, - outputRoot, - projectRoot, - source: script.source, target, - ...(tools === undefined ? {} : { tools }), - }); + }; })); }; /** The artifact-hosted routed CLI bins of the composite root (#387), composed by the build's own planner. */ -const cliBinEntries = ( - model: NormalizedPlugin, - projectRoot: string, - target: string, - tools: AgentBundleToolsConfig | undefined, -): readonly BundlerInspectionEntry[] => { - const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken; - const planned = planCompiledCliBins(model, { outDir: outputRoot, target }); - return cliBinRslibEntries(planned, model).map((entry) => rslibInspectionEntry({ +const cliBinEntries = (model: NormalizedPlugin, outDir: string, target: string): readonly PlannedRslibInspection[] => { + const planned = planCompiledCliBins(model, { outDir, target }); + return cliBinRslibEntries(planned, model).map((entry) => ({ entry, - kind: 'bin', - meta, + kind: 'bin' as const, name: entry.name.replace(/^bin-/u, ''), outputPath: entry.outputRelativePath, - outputRoot, - projectRoot, - source: entry.source, target, - ...(tools === undefined ? {} : { tools }), })); }; const mcpEntryEntries = async ( model: NormalizedPlugin, - projectRoot: string, composite: CompositeSelection, - tools: AgentBundleToolsConfig | undefined, -): Promise => { - const meta = projectMeta(model.metadata); - const outputRoot = artifactOutputToken; + outDir: string, +): Promise => { const target = composite.identity; const noticeDelivery = composite.noticeDelivery; - const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target, targets: composite.selected }); + const planned = planCompiledMcpEntries(model.mcpServers, { outDir, target, targets: composite.selected }); const hosting = eventRuntimeHosting(model.mcpServers, composite.selected); - const entries: BundlerInspectionEntry[] = []; + const entries: PlannedRslibInspection[] = []; for (const entry of planned) { const server = model.mcpServers.find((candidate) => candidate.id === entry.id); const serverName = entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name; @@ -235,7 +219,7 @@ const mcpEntryEntries = async ( ...(model.state === undefined ? {} : { state: model.state }), workerFile, }); - entries.push(rslibInspectionEntry({ + entries.push({ entry: { aliases: { // Every stdio entry can import the operator `.env` layer (#469); the @@ -271,17 +255,12 @@ const mcpEntryEntries = async ( ], }, kind: 'mcp-entry', - meta, name: serverName, outputPath: `mcp/${entry.name}.mjs`, - outputRoot, - projectRoot, - source: entry.source, target, - ...(tools === undefined ? {} : { tools }), - })); + }); if (generatedRoutes !== undefined) { - entries.push(rslibInspectionEntry({ + entries.push({ entry: { name: `${entry.name}-flight`, outputRelativePath: `mcp/${workerFile}`, @@ -301,29 +280,17 @@ const mcpEntryEntries = async ( }), }, kind: 'mcp-entry', - meta, name: `${serverName}:flight`, outputPath: `mcp/${workerFile}`, - outputRoot, - projectRoot, - source: entry.source, target, - ...(tools === undefined ? {} : { tools }), - })); + }); } } - return Object.freeze(entries); + return entries; }; -const hookEntries = ( - entries: readonly TargetHookEntry[], - meta: AgentBundleMeta, - projectRoot: string, - target: string, - tools: AgentBundleToolsConfig | undefined, -): readonly BundlerInspectionEntry[] => { - const outputRoot = artifactOutputToken; - return entries.map((entry) => rslibInspectionEntry({ +const hookEntries = (entries: readonly TargetHookEntry[], target: string): readonly PlannedRslibInspection[] => + entries.map((entry) => ({ entry: { aliases: { [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), @@ -341,25 +308,65 @@ const hookEntries = ( virtualSource: entry.virtualSource, ...(hookWrapperAppliesOperatorEnv(entry) ? { virtualModules: [operatorEnvLayerVirtualModule()] } : {}), }, - kind: 'hook', - meta, + kind: 'hook' as const, name: entry.hook.name, outputPath: entry.relativePath, - outputRoot, - projectRoot, - source: entry.hook.source, target, - ...(tools === undefined ? {} : { tools }), + })); + +const packageBuildEntries = async (model: NormalizedPlugin): Promise => { + const packageBuild = model.packageBuild; + if (packageBuild === undefined) return []; + const dtsTsconfig = packageBuild.lib?.dts === true ? generatedDtsTsconfigToken : undefined; + const planned = await planPackageEntries(model, dtsTsconfig); + return planned.map((entry) => { + const bin = entry.executable; + return { + entry, + kind: bin ? 'bin' as const : 'lib' as const, + name: bin ? entry.name.replace(/^bin-/u, '') : entry.name, + outputPath: `${packageBuild.outputDir}/${entry.outputRelativePath}`, + }; + }); +}; + +/** Lowers one Rslib run's entries and pairs each with its rendered config. */ +const loweredRslibEntries = async ( + planned: readonly PlannedRslibInspection[], + run: { + readonly meta: AgentBundleMeta; + readonly outputRoot: string; + readonly projectRoot: string; + readonly tokens: PathTokens; + readonly tools?: AgentBundleToolsConfig; + }, +): Promise => { + const configs = await inspectRslibEntries({ + cwd: run.projectRoot, + meta: run.meta, + outputRoot: run.outputRoot, + ...(run.tools === undefined ? {} : { tools: run.tools }), + }, planned.map((item) => item.entry)); + return planned.map((item, index) => Object.freeze({ + bundler: 'rslib' as const, + config: renderConfigValue(configs[index], run.tokens), + ...(item.entry.virtualSource === undefined ? {} : { generatedEntry: item.entry.virtualSource }), + kind: item.kind, + name: item.name, + outputPath: item.outputPath, + source: item.entry.source, + ...(item.target === undefined ? {} : { target: item.target }), })); }; -const mcpAppsEntry = ( +const mcpAppEntries = async ( model: NormalizedPlugin, projectRoot: string, composite: CompositeSelection, + outputRoot: string, + tokens: PathTokens, tools: AgentBundleToolsConfig | undefined, -): readonly BundlerInspectionEntry[] => { - const outputRoot = artifactOutputToken; +): Promise => { const target = composite.identity; const apps = model.mcpApps ?? []; const planned = planCompiledMcpApps(apps, { outDir: outputRoot, selected: composite.selected, target }); @@ -371,45 +378,21 @@ const mcpAppsEntry = ( } return source; }); - return [Object.freeze({ + const configs = await inspectMcpAppsConfig(planned, sources, { + cwd: projectRoot, + meta: projectMeta(model.metadata), + outDir: outputRoot, + ...(tools === undefined ? {} : { tools }), + }); + return planned.map((app, index) => Object.freeze({ bundler: 'rsbuild' as const, - config: renderConfigValue(composeMcpAppsRsbuildConfig(sources, { - cwd: projectRoot, - meta: projectMeta(model.metadata), - outDir: outputRoot, - ...(tools === undefined ? {} : { tools }), - })), - kind: 'mcp-apps' as const, - name: 'mcp-apps', - outputPath: 'mcp-apps', + config: renderConfigValue(configs[index], tokens), + kind: 'mcp-app' as const, + name: app.name, + outputPath: `mcp-apps/${app.name}.html`, + source: app.source, target, - })]; -}; - -const packageBuildEntries = async ( - model: NormalizedPlugin, - projectRoot: string, - tools: AgentBundleToolsConfig | undefined, -): Promise => { - const packageBuild = model.packageBuild; - if (packageBuild === undefined) return []; - const dtsTsconfig = packageBuild.lib?.dts === true ? generatedDtsTsconfigToken : undefined; - const planned = await planPackageEntries(model, dtsTsconfig); - const meta = projectMeta(model.metadata); - return planned.map((entry) => { - const bin = entry.executable; - return rslibInspectionEntry({ - entry, - kind: bin ? 'bin' : 'lib', - meta, - name: bin ? entry.name.replace(/^bin-/u, '') : entry.name, - outputPath: `${packageBuild.outputDir}/${entry.outputRelativePath}`, - outputRoot: packageBuild.outputDir, - projectRoot, - source: entry.source, - ...(tools === undefined ? {} : { tools }), - }); - }); + })); }; const entryOrder = (left: BundlerInspectionEntry, right: BundlerInspectionEntry): number => @@ -429,13 +412,40 @@ export const composeBundlerInspection = async (options: { }): Promise => { const { composite, model, projectRoot, tools } = options; const meta = projectMeta(model.metadata); + // The output roots are absolute, as the build passes them and as the + // resolved-config assertions expect them; the rendering folds the token + // root back to its token. + const artifactOutputRoot = resolve(projectRoot, artifactOutputToken); + const tokens: PathTokens = [ + [artifactOutputRoot, artifactOutputToken], + [resolve(projectRoot, generatedDtsTsconfigToken), generatedDtsTsconfigToken], + ]; + // The artifact surfaces ride one Rslib run, as the build stages them; the + // package build is its own run with its own output root. + const artifactSurfaces: readonly PlannedRslibInspection[] = [ + ...(composite.cliBin ? cliBinEntries(model, artifactOutputRoot, composite.identity) : []), + ...(await scriptEntries(model, composite)), + ...(await mcpEntryEntries(model, composite, artifactOutputRoot)), + ...hookEntries(composite.hookEntries, composite.identity), + ]; const entries: BundlerInspectionEntry[] = [ - ...(composite.cliBin ? cliBinEntries(model, projectRoot, composite.identity, tools) : []), - ...(await scriptEntries(model, projectRoot, composite, tools)), - ...(await mcpEntryEntries(model, projectRoot, composite, tools)), - ...hookEntries(composite.hookEntries, meta, projectRoot, composite.identity, tools), - ...mcpAppsEntry(model, projectRoot, composite, tools), - ...(await packageBuildEntries(model, projectRoot, tools)), + ...(await loweredRslibEntries(artifactSurfaces, { + meta, + outputRoot: artifactOutputRoot, + projectRoot, + tokens, + ...(tools === undefined ? {} : { tools }), + })), + ...(await mcpAppEntries(model, projectRoot, composite, artifactOutputRoot, tokens, tools)), + ...(model.packageBuild === undefined + ? [] + : await loweredRslibEntries(await packageBuildEntries(model), { + meta, + outputRoot: resolve(projectRoot, model.packageBuild.outputDir), + projectRoot, + tokens, + ...(tools === undefined ? {} : { tools }), + })), ]; return deepFreeze({ entries: entries.sort(entryOrder), diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 2b156a722..bc15bdb07 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -43,6 +43,7 @@ import { virtualModulesPluginConstructor, } from './meta.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; +import { inspectProductionConfig } from './rslib.ts'; import { runtimeIgnoredRoot } from './runtime-path.ts'; export type { McpAppCompileMode, McpAppOutputSize } from './mcp-app-diagnostics.ts'; @@ -456,6 +457,54 @@ const assertViewsSelfContained = ( if (diagnostics.length > 0) throw new DiagnosticError(diagnostics); }; +/** + * The Rsbuild instance every view of one composite root compiles through — + * the composed profile plus the compile-time plugins the build adds beside + * it — lowered to its resolved environments and Rspack configurations and + * judged by {@link assertResolvedViewConfig} before anything compiles. The + * build and `inspect --bundler` share this step, so what the inspection + * renders is exactly what the build compiles. + */ +const lowerViews = async ( + compiled: readonly PlannedMcpApp[], + sources: readonly Pick[], + options: Parameters[1], + collectedStats: Map, +): Promise<{ + readonly inspection: Awaited>['inspectConfig']>>; + readonly rsbuild: Awaited>; +}> => { + const rsbuild = await createRsbuild({ cwd: options.cwd, config: composeMcpAppsRsbuildConfig(sources, options) }); + rsbuild.addPlugins([mcpAppStatsCollectorPlugin(collectedStats), mcpAppHtmlDefaultsPlugin()]); + const inspection = await inspectProductionConfig(rsbuild); + assertResolvedViewConfig( + inspection, + compiled.map((app) => app.name), + options.outDir, + generatedMetaModulePath(options.cwd), + appRuntimePath(), + ); + return { inspection, rsbuild }; +}; + +/** + * The lowered Rspack configuration of every planned view, in plan order, + * from one Rsbuild instance that never builds: the same composition, mode, + * and invariant assertions as {@link compileMcpApps}, stopping where the + * build would start compiling. Rsbuild reads every view entry from disk + * while resolving, exactly as the build does. + */ +export const inspectMcpAppsConfig = async ( + compiled: readonly PlannedMcpApp[], + sources: readonly Pick[], + options: Parameters[1], +): Promise => { + if (compiled.length === 0) return Object.freeze([]); + const { inspection } = await lowerViews(compiled, sources, options, new Map()); + // `assertResolvedViewConfig` has matched the compiler names to the app names. + return Object.freeze(compiled.map((app) => inspection.origin.bundlerConfigs.find((config) => config.name === app.name)!)); +}; + export const compileMcpApps = async ( apps: readonly NormalizedMcpApp[], options: Readonly<{ @@ -484,28 +533,15 @@ export const compileMcpApps = async ( const mode: McpAppCompileMode = options.mode ?? 'production'; const compilationEvidence: CompilationEvidence[] = []; - const rsbuild = await createRsbuild({ - cwd: options.cwd, - config: composeMcpAppsRsbuildConfig(sources, { - cwd: options.cwd, - meta: options.meta, - mode, - onCompilationEvidence: (evidence) => compilationEvidence.push(evidence), - outDir: options.outDir, - ...(options.tools === undefined ? {} : { tools: options.tools }), - }), - }); const collectedStats = new Map(); - rsbuild.addPlugins([mcpAppStatsCollectorPlugin(collectedStats), mcpAppHtmlDefaultsPlugin()]); - const inspection = await rsbuild.inspectConfig({ mode: 'production' }); - const runtimePath = appRuntimePath(); - assertResolvedViewConfig( - inspection, - compiled.map((app) => app.name), - options.outDir, - generatedMetaModulePath(options.cwd), - runtimePath, - ); + const { rsbuild } = await lowerViews(compiled, sources, { + cwd: options.cwd, + meta: options.meta, + mode, + onCompilationEvidence: (evidence) => compilationEvidence.push(evidence), + outDir: options.outDir, + ...(options.tools === undefined ? {} : { tools: options.tools }), + }, collectedStats); const contexts: readonly McpAppDiagnosticContext[] = compiled.map((app) => ({ appName: app.name, entrySource: app.source, @@ -544,7 +580,7 @@ export const compileMcpApps = async ( // stats as a module under this reserved namespace. ignoredSourcePaths: [ resolve(generatedModulesRoot(options.cwd)), - runtimeIgnoredRoot(runtimePath), + runtimeIgnoredRoot(appRuntimePath()), ], projectRoot: options.cwd, stats: result.stats, diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 71ca84514..12bc1eb14 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -80,22 +80,22 @@ const preserveResourceReferences: RslibBundlerChain = (chain) => { }; /** - * Lowers the composed lib configs to bundler configs, always as the - * production build `rslib.build()` runs. Rslib 1.x otherwise infers the mode - * from `NODE_ENV`, and under `development` inspects only `mf` libs — none - * here — so `assertExecutableConfig` and `inspect --bundler` would fail. Rslib - * writes the inspected mode back to `NODE_ENV`; the process gets its own value - * back — set or unset — whether the inspection succeeded or threw, so a - * development server or test runner that inspects a config is not left - * running as `production`. (`rslib.build()` sets `production` itself when it - * finds `NODE_ENV` unset; that is the build's business, not the inspection's.) + * Lowers composed configs to bundler configs, always as the production build + * runs. Rslib 1.x otherwise infers the mode from `NODE_ENV`, and under + * `development` inspects only `mf` libs — none here — so + * `assertExecutableConfig` and `inspect --bundler` would fail. Rslib and + * Rsbuild write the inspected mode back to `NODE_ENV`; the process gets its + * own value back — set or unset — whether the inspection succeeded or threw, + * so a development server or test runner that inspects a config is not left + * running as `production`. (`build()` sets `production` itself when it finds + * `NODE_ENV` unset; that is the build's business, not the inspection's.) */ -const inspectProductionConfig = async ( - rslib: Pick, -): Promise>> => { +export const inspectProductionConfig = async ( + instance: { readonly inspectConfig: (options: { readonly mode: 'production' }) => Promise }, +): Promise => { const nodeEnv = process.env.NODE_ENV; try { - return await rslib.inspectConfig({ mode: 'production' }); + return await instance.inspectConfig({ mode: 'production' }); } finally { if (nodeEnv === undefined) delete process.env.NODE_ENV; else process.env.NODE_ENV = nodeEnv; @@ -515,8 +515,9 @@ const assertExecutableConfig = ( * 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. + * `buildRslibSurfaces` and `inspectRslibEntries` lower exactly this + * composition through one shared step, so the build and `inspect --bundler` + * can never drift. */ export const composeEntryLibConfig = ( entry: RslibEntry, @@ -789,6 +790,67 @@ const moduleKindOf = ( return 'authored'; }; +/** + * Creates the Rslib instance for one run — every entry composed through + * {@link composeEntryLibConfig} — and lowers it to the resolved Rsbuild + * environments and Rspack configurations, judged by + * {@link assertExecutableConfig} before anything compiles. The build and + * `inspect --bundler` share this step, so what the inspection renders is + * exactly what the build compiles. + */ +const lowerEntries = async ( + options: RslibRunOptions, + entries: readonly RslibEntry[], + lowering: { + readonly createRslib: NonNullable; + readonly logLevel: 'error' | 'silent'; + readonly onCompilationEvidence?: (evidence: CompilationEvidence) => void; + readonly onReservedExternal?: (specifier: string) => void; + }, +): Promise<{ + readonly inspection: Awaited>; + readonly rslib: Awaited>>; +}> => { + const rslib = await lowering.createRslib({ + cwd: options.cwd, + config: { + logLevel: lowering.logLevel, + lib: entries.map((entry) => composeEntryLibConfig(entry, { + cwd: options.cwd, + meta: options.meta, + ...(lowering.onCompilationEvidence === undefined ? {} : { onCompilationEvidence: lowering.onCompilationEvidence }), + ...(lowering.onReservedExternal === undefined ? {} : { onReservedExternal: lowering.onReservedExternal }), + outputRoot: options.outputRoot, + ...(options.tools === undefined ? {} : { tools: options.tools }), + })), + }, + }); + const inspection = await inspectProductionConfig(rslib); + assertExecutableConfig(entries, inspection.origin, options); + return { inspection, rslib }; +}; + +/** + * The lowered Rspack configuration of every entry, in entry order, from one + * Rslib instance that never builds: the same composition, mode, and + * invariant assertions as {@link buildRslibSurfaces}, stopping where the + * build would start compiling. Rslib reads every authored entry from disk + * while resolving, exactly as the build does. + */ +export const inspectRslibEntries = async ( + options: RslibRunOptions, + entries: readonly RslibEntry[], +): Promise => { + if (entries.length === 0) return Object.freeze([]); + assertDistinctLibIds(entries); + const { inspection } = await lowerEntries(options, entries, { createRslib, logLevel: 'silent' }); + // `assertExecutableConfig` has matched exactly one lowered config per entry by lib id. + return Object.freeze(entries.map((entry) => { + const id = entryLibId(entry); + return inspection.origin.bundlerConfigs.find((config) => config.name === id)!; + })); +}; + /** * Lowers every surface's entries through one Rslib instance and returns the * compiler result per surface, in surface order. A surface without entries @@ -814,24 +876,13 @@ export const buildRslibSurfaces = async ( const reservedExternalViolations: string[] = []; const compilationEvidence: CompilationEvidence[] = [...(dependencies.compilationEvidence ?? [])]; - const rslib = await (dependencies.createRslib ?? createRslib)({ - cwd: options.cwd, - config: { - // The run reports at the most verbose level any surface asks for. - logLevel: surfaces.some((surface) => surface.logLevel === 'error') ? 'error' : 'silent', - lib: entries.map((entry) => composeEntryLibConfig(entry, { - cwd: options.cwd, - meta: options.meta, - onCompilationEvidence: (evidence) => compilationEvidence.push(evidence), - onReservedExternal: (specifier) => reservedExternalViolations.push(specifier), - outputRoot: options.outputRoot, - ...(options.tools === undefined ? {} : { tools: options.tools }), - })), - }, + const { rslib } = await lowerEntries(options, entries, { + createRslib: dependencies.createRslib ?? createRslib, + // The run reports at the most verbose level any surface asks for. + logLevel: surfaces.some((surface) => surface.logLevel === 'error') ? 'error' : 'silent', + onCompilationEvidence: (evidence) => compilationEvidence.push(evidence), + onReservedExternal: (specifier) => reservedExternalViolations.push(specifier), }); - - const inspection = await inspectProductionConfig(rslib); - assertExecutableConfig(entries, inspection.origin, options); let result: Awaited> | undefined; try { try { diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 832ff0bc2..862f62b43 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -856,7 +856,7 @@ it('reports an unselected inspect target on JSON and human output', async () => } }, 30_000 * timeScale); -it('dumps the synthesized bundler configuration with inspect --bundler', async () => { +it('dumps the lowered Rspack configuration of every output with inspect --bundler', async () => { const project = await createCliProject(); try { await mkdir(join(project.root, 'src'), { recursive: true }); @@ -867,7 +867,7 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async ( " plugin: { name: 'cli-fixture', version: '1.0.0' },", " targets: ['portable'],", " scripts: { tool: './src/tool.ts' },", - " tools: { rspack: { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } } },", + " tools: { rspack: { resolve: { alias: { '@fixture': './src' } } } },", '};', '', ].join('\n'), @@ -880,7 +880,12 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async ( readonly selected: { readonly bundler: { readonly entries: readonly { - readonly config: { readonly tools: { readonly rspack: readonly unknown[] } }; + readonly config: { + readonly name: string; + readonly output: { readonly path: string }; + readonly plugins: readonly string[]; + readonly resolve: { readonly alias: Readonly> }; + }; readonly kind: string; readonly name: string; }[]; @@ -888,18 +893,24 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async ( }; }; const script = document.selected.bundler.entries.find((entry) => entry.kind === 'script'); + // The dump is what Rslib resolved for the compiler, not the authored + // config: the hatch alias sits beside the framework's reserved ones, and + // the per-build artifact root appears as its stable token. expect(script).toMatchObject({ config: { - output: { distPath: { root: '' } }, - tools: { - rspack: [ - { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }, - '[function enforceInvariants]', - ], + name: 'agent-bundle-scripts-tool', + output: { filename: 'scripts/tool.mjs', path: '' }, + resolve: { + alias: { + '@fixture': './src', + 'agent-bundle/meta$': `${project.root}/.agent-bundle-virtual/meta.mjs`, + }, }, }, name: 'tool', }); + expect(script?.config.plugins).toContain('[object VirtualModulesPlugin]'); + expect(json.stdout).not.toContain(`${project.root}/`); const repeated = await runSourceCliWithOutput(['inspect', '--root', project.root, '--bundler', '--json']); expect(repeated.stdout).toBe(json.stdout); @@ -907,7 +918,8 @@ it('dumps the synthesized bundler configuration with inspect --bundler', async ( const human = await runSourceCliWithOutput(['inspect', '--root', project.root, '--bundler']); expect(human).toMatchObject({ code: 0, stderr: '' }); expect(human.stdout).toContain('"kind": "script"'); - expect(human.stdout).toContain('[function enforceInvariants]'); + expect(human.stdout).toContain('"name": "agent-bundle-scripts-tool"'); + expect(human.stdout).toContain('[object ArtifactDependencyAuditPlugin]'); const ambiguous = await runSourceCliWithOutput(['inspect', '--root', project.root, '--bundler', '--skills']); expect(ambiguous.code).toBe(1); diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index 4ecc448dd..0aac0f73c 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -6,10 +6,13 @@ import { afterEach, expect, it } from '@rstest/core'; import { planHooks } from '../src/adapters/hook-contract.ts'; import { inspect, type BundlerInspectionEntry, type ReadyInspectResult } from '../src/api.ts'; +import { appRuntimePath } from '../src/build/app-runtime.ts'; +import { launchEnvRuntimePath, mcpEntryRuntimePath, terminalCapabilityRuntimePath } from '../src/build/entry-shell.ts'; import { composeBundlerInspection } from '../src/build/inspect-bundler.ts'; import { stableJson } from '../src/core/digest.ts'; import type { NormalizedHook, NormalizedPlugin } from '../src/core/types.ts'; import type { CompiledEventPreflight } from '../src/routes/types.ts'; +import { workspaceNodeModules } from './helpers/workspace-paths.ts'; const roots: string[] = []; @@ -17,12 +20,19 @@ afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); }); +/** + * The lowering runs the same Rslib/Rsbuild config resolution the build runs, + * which reads every authored entry from disk and — for the `dts` lib entry — + * resolves the project's own TypeScript, so the fixture links one in. + */ const createProject = async (): Promise => { const parent = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-inspect-bundler-'))); roots.push(parent); const root = join(parent, 'project'); await mkdir(join(root, 'src', 'mcp'), { recursive: true }); + await mkdir(join(root, 'node_modules'), { recursive: true }); await Promise.all([ + symlink(join(workspaceNodeModules, 'typescript'), join(root, 'node_modules', 'typescript'), 'dir'), writeFile(join(root, 'package.json'), '{"type":"module"}\n'), writeFile( join(root, 'agent-bundle.config.ts'), @@ -41,7 +51,7 @@ const createProject = async (): Promise => { ' },', ' },', ' tools: {', - " rsbuild: { output: { legalComments: 'linked' } },", + " rsbuild: { resolve: { alias: { '@fixture': './src' } } },", " rspack: { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } },", ' },', '};', @@ -76,7 +86,27 @@ const entryOf = ( return entry; }; -it('surfaces every synthesized bundler config with the tools hatch merged over the profile', async () => { +/** The lowered Rspack configuration of one entry, typed loosely for the assertions. */ +interface LoweredConfig { + readonly context?: string; + readonly entry?: Readonly>; + readonly externals?: readonly unknown[]; + readonly mode?: string; + readonly module?: { readonly parser?: { readonly javascript?: Readonly> } }; + readonly name?: string; + readonly output?: Readonly>; + readonly plugins?: readonly string[]; + readonly resolve?: { + readonly alias?: Readonly>; + readonly extensionAlias?: Readonly>; + readonly tsConfig?: { readonly configFile?: string }; + }; + readonly target?: readonly string[]; +} + +const loweredConfig = (entry: BundlerInspectionEntry): LoweredConfig => entry.config as LoweredConfig; + +it('renders the lowered Rspack configuration of every compiled output with the tools hatch applied', async () => { const root = await createProject(); const result = await inspect({ focus: 'bundler', root }); expect(result.state).toBe('ready'); @@ -86,10 +116,18 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t expect(entries.map((entry) => `${entry.target ?? 'package'}:${entry.kind}:${entry.name}`)).toEqual([ 'package:bin:bundler-fixture', 'package:lib:index', - 'portable:mcp-apps:mcp-apps', + 'portable:mcp-app:dashboard', 'portable:mcp-entry:curator', 'portable:script:tool', ]); + // One lowered compiler per entry, named as Rslib and Rsbuild name it. + expect(entries.map((entry) => loweredConfig(entry).name)).toEqual([ + 'agent-bundle-bin-bundler-fixture', + 'agent-bundle-index', + 'dashboard', + expect.stringMatching(/^agent-bundle-mcp-mcp-curator-[a-f\d]{8}$/u), + 'agent-bundle-scripts-tool', + ]); const script = entryOf(entries, 'script', 'tool'); expect(script).toMatchObject({ @@ -98,98 +136,120 @@ it('surfaces every synthesized bundler config with the tools hatch merged over t source: `${root}/src/tool.ts`, target: 'portable', }); - // The generated executable envelope wraps the `main` export. + // The generated executable envelope wraps the `main` export, and the + // lowered entry is redirected to it in the reserved virtual namespace. expect(script.generatedEntry).toContain('process.argv.slice(2)'); - expect(script.config).toMatchObject({ - id: 'agent-bundle-scripts-tool', - // Routes are authored as TSX, so every Rslib entry carries the React - // plugin: without it JSX lowers to a `React` factory that no generated - // executable has in scope. - plugins: [{ name: 'rsbuild:react' }], + const scriptConfig = loweredConfig(script); + expect(scriptConfig).toMatchObject({ + context: root, + entry: { tool: [`${root}/.agent-bundle-virtual/tool-entry.mjs`] }, + mode: 'production', + // Generated code spells run-time paths as `new URL(…, import.meta.url)`; + // the invariant layer keeps the bundler from turning them into assets. + module: { parser: { javascript: { url: false, worker: false } } }, output: { - distPath: { root: '' }, - filename: { js: 'scripts/tool.mjs' }, - // The consumer rsbuild hatch merges over the framework profile value. - legalComments: 'linked', - target: 'node', + asyncChunks: false, + // Rslib lowers the per-entry filename to its own resolver function. + filename: '[function jsFilename]', + module: true, + // The per-build artifact root appears as the stable token. + path: '', }, - syntax: 'es2022', - tools: { - // Rslib's `new URL()` rule is switched off so filesystem URLs in - // generated and plugin code survive verbatim. - bundlerChain: '[function preserveResourceReferences]', - // The consumer rspack hatch is merged before the framework invariant - // hook, which always runs last. - rspack: [ - { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }, - '[function enforceInvariants]', - ], + resolve: { + alias: { + // The consumer rsbuild hatch merges over the framework profile. + '@fixture': `${root}/src`, + // The framework's reserved aliases land as exact-match keys. + 'agent-bundle/meta$': `${root}/.agent-bundle-virtual/meta.mjs`, + 'agent-bundle/terminal-capability$': terminalCapabilityRuntimePath(), + }, }, }); + expect(scriptConfig.target).toContain('node'); + // The consumer rspack hatch is merged before the invariant hook and lands in + // the lowered config after Rsbuild's own defaults. + expect(scriptConfig.resolve?.extensionAlias?.['.js']?.slice(-2)).toEqual(['.js', '.ts']); + // Rslib's node target leaves only the Node built-ins (and pnpapi) external. + expect(scriptConfig.externals).toEqual(expect.arrayContaining(['fs', '[regexp /^node:/]', 'pnpapi'])); + // Framework plugins survive the hatch: the generated sources are served + // from memory and the dependency audit reads the final module graph. + expect(scriptConfig.plugins).toEqual(expect.arrayContaining([ + '[object VirtualModulesPlugin]', + '[object ArtifactDependencyAuditPlugin]', + ])); const mcpEntry = entryOf(entries, 'mcp-entry', 'curator'); expect(mcpEntry.generatedEntry).toContain('runGeneratedStdioMcpEntry'); expect(mcpEntry.source).toBe(`${root}/src/mcp/curator.ts`); expect(mcpEntry.outputPath).toMatch(/^mcp\/mcp-curator-[a-f\d]{8}\.mjs$/u); + expect(loweredConfig(mcpEntry)).toMatchObject({ + output: { filename: '[function jsFilename]', path: '' }, + resolve: { + alias: { + 'agent-bundle/launch-env$': launchEnvRuntimePath(), + 'agent-bundle/mcp-entry$': mcpEntryRuntimePath(), + }, + }, + }); const bin = entryOf(entries, 'bin', 'bundler-fixture'); expect(bin).toMatchObject({ - config: { - banner: { js: '#!/usr/bin/env node' }, - output: { distPath: { root: 'dist' } }, - }, outputPath: 'dist/bin/bundler-fixture.js', source: `${root}/src/cli.ts`, }); expect(bin.generatedEntry).toContain('process.argv.slice(2)'); + const binConfig = loweredConfig(bin); + // The package build's output root is its published destination. + expect(binConfig.output).toMatchObject({ filename: '[function jsFilename]', path: `${root}/dist` }); + // The shebang banner lowers to Rspack's banner plugin. + expect(binConfig.plugins).toContain('[object BannerPlugin]'); const lib = entryOf(entries, 'lib', 'index'); - expect(lib).toMatchObject({ - config: { - dts: true, - source: { tsconfigPath: '' }, - }, - outputPath: 'dist/index.js', - }); + expect(lib.outputPath).toBe('dist/index.js'); + // The declaration tsconfig the package build synthesizes appears as its token. + expect(loweredConfig(lib).resolve?.tsConfig).toEqual({ configFile: '', references: 'auto' }); - const apps = entryOf(entries, 'mcp-apps', 'mcp-apps'); - expect(apps.bundler).toBe('rsbuild'); - expect(apps.config).toMatchObject({ - environments: { - dashboard: { - // Every view carries the React plugin and the document defaults - // (mount point, title = the App name), whatever its entry extension. - html: { inject: 'body', mountId: 'root', title: 'dashboard' }, - plugins: [{ name: 'rsbuild:react' }], - source: { entry: { dashboard: `${root}/src/view.tsx` } }, + const app = entryOf(entries, 'mcp-app', 'dashboard'); + expect(app).toMatchObject({ + bundler: 'rsbuild', + outputPath: 'mcp-apps/dashboard.html', + source: `${root}/src/view.tsx`, + target: 'portable', + }); + expect(app.generatedEntry).toBeUndefined(); + const appConfig = loweredConfig(app); + expect(appConfig).toMatchObject({ + entry: { dashboard: [`${root}/src/view.tsx`] }, + mode: 'production', + output: { asyncChunks: false, path: '' }, + resolve: { + alias: { + // The consumer rsbuild hatch also merges over the view profile. + '@fixture': `${root}/src`, + 'agent-bundle/app$': appRuntimePath(), + 'agent-bundle/meta$': `${root}/.agent-bundle-virtual/meta.mjs`, }, }, - output: { - distPath: { html: 'mcp-apps', root: '' }, - inlineScripts: true, - // The consumer rsbuild hatch also merges over the view profile. - legalComments: 'linked', - // The inspection renders the production profile: no source maps. - sourceMap: false, - }, - tools: { - rspack: [ - { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }, - '[function enforceInvariants]', - ], - }, }); + expect(appConfig.target).toContain('web'); + expect(appConfig.plugins).toEqual(expect.arrayContaining([ + '[object NormalModuleReplacementPlugin]', + '[object VirtualModulesPlugin]', + '[object ArtifactDependencyAuditPlugin]', + ])); }); -it('keeps the bundler inspection deterministic and JSON-serializable', async () => { +it('keeps the bundler inspection deterministic, JSON-serializable, and free of the staged output path', async () => { const root = await createProject(); const [first, second] = await Promise.all([ inspect({ focus: 'bundler', root }), inspect({ focus: 'bundler', root }), ]); expect(first.state).toBe('ready'); - expect(stableJson((first as ReadyInspectResult).selected)) - .toBe(stableJson((second as ReadyInspectResult).selected)); + const rendered = stableJson((first as ReadyInspectResult).selected); + expect(rendered).toBe(stableJson((second as ReadyInspectResult).selected)); + expect(rendered).not.toContain(`${root}/`); + expect(rendered).not.toContain(`${root}/`); }); it('keeps the bundler focus out of unfocused inspections', async () => { @@ -199,18 +259,51 @@ it('keeps the bundler focus out of unfocused inspections', async () => { expect((result as ReadyInspectResult).selected).toBeUndefined(); }); +it('reports a tools hatch the lowering refuses as an invalid inspection naming the refusal', async () => { + const root = await createProject(); + await writeFile( + join(root, 'agent-bundle.config.ts'), + [ + 'export default {', + " plugin: { name: 'bundler-fixture', version: '1.0.0' },", + " targets: ['portable'],", + " scripts: { tool: './src/tool.ts' },", + // Aliasing a reserved specifier is refused only once the invariant hook + // runs over the lowered config, so it surfaces from the inspection. + " tools: { rspack: { resolve: { alias: { 'agent-bundle/meta': './src/index.ts' } } } },", + '};', + '', + ].join('\n'), + ); + const result = await inspect({ focus: 'bundler', root }); + expect(result.state).toBe('invalid'); + expect(result.diagnostics).toEqual([expect.objectContaining({ + code: 'AB7001', + message: expect.stringContaining('The tools escape hatch must not alias the reserved specifier matched by "agent-bundle/meta"'), + })]); +}); + it('inspects the per-host preflight wrapper under the composite identity', async () => { + const parent = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-inspect-hooks-'))); + roots.push(parent); + const root = join(parent, 'project'); + await mkdir(join(root, 'src', 'events', 'tool'), { recursive: true }); + await Promise.all([ + writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(join(root, 'src', 'events', 'tool', 'before.tsx'), 'export default () => undefined;\n'), + writeFile(join(root, 'src', 'events', 'tool', 'before.preflight.ts'), 'export default () => true;\n'), + ]); const preflight: CompiledEventPreflight = Object.freeze({ provenance: Object.freeze({ kind: 'conventional' as const, relativePath: 'src/events/tool/before.preflight.ts' }), - source: '/project/src/events/tool/before.preflight.ts', + source: `${root}/src/events/tool/before.preflight.ts`, }); const hook: NormalizedHook = { event: 'beforeTool', eventRoute: { event: 'tool/before', fallback: 'none', preflight, runtime: 'shared' }, id: 'hook:event-route:tool-before', name: 'event-route-tool-before', - provenance: { kind: 'conventional', sourcePath: '/project/src/events/tool/before.tsx' }, - source: '/project/src/events/tool/before.tsx', + provenance: { kind: 'conventional', sourcePath: `${root}/src/events/tool/before.tsx` }, + source: `${root}/src/events/tool/before.tsx`, targets: ['claude', 'codex'], tools: [], }; @@ -221,7 +314,7 @@ it('inspects the per-host preflight wrapper under the composite identity', async metadata: { id: 'plugin:preflight-inspect', name: 'preflight-inspect', - provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + provenance: { kind: 'config', sourcePath: `${root}/agent-bundle.config.ts` }, version: '1.0.0', }, runtime: { node: '22.12.0' }, @@ -230,7 +323,7 @@ it('inspects the per-host preflight wrapper under the composite identity', async targets: ['claude', 'codex'].map((name) => ({ id: `target:${name}`, name, - provenance: { kind: 'config' as const, sourcePath: '/project/agent-bundle.config.ts' }, + provenance: { kind: 'config' as const, sourcePath: `${root}/agent-bundle.config.ts` }, })), }; const hookEntries = ['claude', 'codex'].flatMap((host) => planHooks(model, host, { @@ -254,7 +347,7 @@ it('inspects the per-host preflight wrapper under the composite identity', async selected: ['claude', 'codex'], }, model, - projectRoot: '/project', + projectRoot: root, }); const hooks = inspection.entries.filter((entry) => entry.kind === 'hook'); expect(hooks.map((entry) => entry.outputPath).sort()).toEqual([ @@ -268,5 +361,11 @@ it('inspects the per-host preflight wrapper under the composite identity', async expect(entry.generatedEntry).toContain('agent-bundle/event-project'); expect(entry.generatedEntry).toContain('.execute.mjs'); expect(entry.generatedEntry).not.toContain('AGENT_BUNDLE_HOOK_HOST'); + // Every wrapper lowers to its own compiler writing its own file into the one root. + expect(loweredConfig(entry)).toMatchObject({ + name: `agent-bundle-${entry.outputPath.replace(/\.mjs$/u, '').replaceAll('/', '-')}`, + output: { filename: '[function jsFilename]', path: '' }, + resolve: { alias: { 'agent-bundle/launch-env$': launchEnvRuntimePath() } }, + }); } }); diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 147287c81..eac8a28d2 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -537,9 +537,15 @@ npx agent-bundle inspect --bundler --root . npx agent-bundle inspect --bundler --root . --target claude --json ``` -That dumps the synthesized configuration for every output the build composes, exactly as the -build lowers it, using the same functions the build uses — so the dump cannot drift from what -compiles. Entries the framework wraps also carry the generated wrapper module source. +That dumps the lowered Rspack configuration of every output the build compiles — one entry per +compiler, produced by the build's own Rslib (executables) or Rsbuild (MCP App views) lowering and +stopped where the build would start compiling — so the dump cannot drift from what compiles. You +see the resolved `resolve.alias` map (framework runtime modules beside your own aliases), the +`externals` list, the plugins as `[object ]`, functions as `[function ]`, and +`output.path` as `` (the artifact root) or `/dist` (the package build). A +hatch value the build's invariants refuse — aliasing a reserved `agent-bundle/*` specifier, for +instance — makes the inspection invalid with `AB7001` naming the refusal. Entries the framework +wraps also carry the generated wrapper module source. The full contract for everything on this page is in [Entry conventions](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/entry-conventions.md). diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx index 8cef7d7e1..753db93ca 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -46,7 +46,7 @@ hosts sorted and joined by `+`, such as `claude+codex` — never once per host. `dist/` package build compose their bundler config the same way — profile, `tools.rsbuild`, `tools.rspack`, then the framework invariants — as described under [`tools`](../../reference/configuration.mdx#tools). `agent-bundle inspect --bundler` prints the -result; its `distPath.root` is the artifact output itself. +lowered Rspack configuration of every compiled output; its `output.path` is the artifact output itself. ## What ships inside the root diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index d02c90042..513d8641f 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -287,7 +287,7 @@ from `eval`. | Flag | Focus | | --- | --- | -| `--bundler` | The synthesized bundler configuration, lowered as the production build lowers it regardless of `NODE_ENV`. Its `distPath.root` is the artifact output, ``, because every selected projection compiles into the one composite root. | +| `--bundler` | The lowered Rspack configuration of every compiled output — what Rslib (executables) or Rsbuild (MCP App views) hands the compiler after the `tools` hatch and the framework invariants, lowered in production mode regardless of `NODE_ENV`. Resolved aliases, externals, plugins (`[object ]`), and functions (`[function ]`) are rendered in place; `output.path` is the artifact output, ``, because every selected projection compiles into the one composite root. A `tools` value the build would refuse makes the inspection invalid with `AB7001`. | | `--hooks` | Hooks. | | `--routes` | The compiled route graph. | | `--skills` | Skills. | diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 7b0b42f8e..84dce8443 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -480,8 +480,13 @@ npx agent-bundle inspect --bundler --root . npx agent-bundle inspect --bundler --root . --target claude --json ``` -它会把构建所组合的每个输出的合成配置,按构建实际降级的样子原样导出;由于用的就是构建所用的同一批 -函数,这份导出不可能与真实编译结果发生漂移。被框架包装的入口还会附带生成的包装模块源码。 +它会导出构建所编译的每个输出降级后的 Rspack 配置——每个编译器一条记录,由构建自己的 Rslib(可执行 +文件)或 Rsbuild(MCP App 视图)降级流程产出,并在构建即将开始编译的位置停下——因此这份导出不可能与 +真实编译结果发生漂移。你能看到解析后的 `resolve.alias` 映射(框架运行时模块与你自己的别名并列)、 +`externals` 列表、以 `[object ]` 渲染的插件、以 `[function ]` 渲染的函数,以及 +`output.path`:产物根目录显示为 ``,包构建显示为 `<项目根>/dist`。被构建不变量拒绝的逃生舱值 +——例如给保留的 `agent-bundle/*` 说明符设置别名——会让检查变为无效,并以 `AB7001` 指出被拒绝的原因。 +被框架包装的入口还会附带生成的包装模块源码。 本页所有内容的完整契约见 [Entry conventions](https://github.com/ScriptedAlchemy/agent-bundle/blob/main/docs/entry-conventions.md)。 diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx index a1783cc89..1cee726b3 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -39,7 +39,7 @@ npx agent-bundle build --root . --output artifact 编译产出面只构建一次,归属于**组合身份**——所选宿主按名称排序并以 `+` 连接,例如 `claude+codex`—— 绝不会逐宿主各编译一次。两个阶段与 `dist/` 包构建以同样的方式合成打包器配置——profile、`tools.rsbuild`、 `tools.rspack`,最后是框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。 -`agent-bundle inspect --bundler` 会打印合成结果;它的 `distPath.root` 就是产物输出本身。 +`agent-bundle inspect --bundler` 会打印每个编译输出降级后的 Rspack 配置;它的 `output.path` 就是产物输出本身。 ## 根目录里发布了什么 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 9bbd1f448..c831d5ca4 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -266,7 +266,7 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst | 标志 | 聚焦于 | | --- | --- | -| `--bundler` | 合成出来的打包器配置,无论 `NODE_ENV` 为何,都按生产构建的方式降级。它的 `distPath.root` 就是产物输出 ``,因为每个所选投影都编译进同一个组合根目录。 | +| `--bundler` | 每个编译输出降级后的 Rspack 配置——即 `tools` 逃生舱与框架不变量应用之后,Rslib(可执行文件)或 Rsbuild(MCP App 视图)真正交给编译器的内容,无论 `NODE_ENV` 为何都按生产模式降级。解析后的别名、externals、插件(`[object ]`)和函数(`[function ]`)就地渲染;`output.path` 就是产物输出 ``,因为每个所选投影都编译进同一个组合根目录。构建会拒绝的 `tools` 值会让检查变为无效并报告 `AB7001`。 | | `--hooks` | 钩子。 | | `--routes` | 编译后的路由图。 | | `--skills` | Skill。 | From 9f3e17e45082ae621b530b2db6d5b9e2590914d8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:00:43 +0000 Subject: [PATCH 02/10] docs: build TypeDoc from declarations --- .github/workflows/docs.yml | 2 ++ AGENTS.md | 3 +- docs/effect-conventions.md | 2 +- website/rspress.config.ts | 55 ++++++++++++++++------------------ website/tsconfig.twoslash.json | 24 +++++++++++++++ website/tsconfig.typedoc.json | 41 ++++--------------------- 6 files changed, 60 insertions(+), 67 deletions(-) create mode 100644 website/tsconfig.twoslash.json diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ba8530f23..a912ff186 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -61,6 +61,8 @@ jobs: node-version: 22.19.0 # Website-only PRs skip every ci.yml job that lints, so lint here too. - run: pnpm lint + # TypeDoc consumes the declarations shipped from package dist trees. + - run: pnpm build # Typecheck the site config, run the locale-drift and diagnostics- # coverage checks (website/scripts), build with the persistent Rspack # cache off, then walk the emitted HTML for dead links and anchors. diff --git a/AGENTS.md b/AGENTS.md index 3b4344d69..b6e80c432 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,8 @@ dead-link, dead-anchor, dead-image, and language-parity checks. Parity fails the build if one locale gains a page the other lacks. Run it before pushing anything under `website/`, and after any change to public - exports, since TypeDoc compiles `packages/agent-bundle/src` directly. + exports, after `pnpm build`, since TypeDoc compiles the declarations under + `packages/agent-bundle/dist`. - The site is desktop-first, like the Workbench. Wide tables scroll; code samples wrap at roughly 90 columns so they render without horizontal overflow at the default content width. diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 2e1a2f2f4..c1ff14e35 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -11,7 +11,7 @@ docs, or examples' user code. The four-concept newcomer ledger is untouched. | --- | --- | | npm `effect` | **`4.0.0-rc.112`** (exact). Latest published `rc` dist-tag on 2026-09-01. The Wave 3.5 brief named `4.0.0-rc.113`; that version was not on the registry. Re-pin chores take the next published RC. | | Vendored tree | `repos/effect` via `git subtree` from [Effect-TS/effect](https://github.com/Effect-TS/effect.git) `main` (v4). Squash commit tracks `packages/effect` version **4.0.0-rc.112**. | -| `website` `typescript` | **`6.0.3`**, behind the root's TypeScript 7, because `typedoc@0.28` peers on `<= 6.0.x`. TypeDoc and twoslash compile `packages/agent-bundle/src` with it, so TS7-only syntax in the package breaks `pnpm docs:site:build` first. Re-pin chores check whether a newer `typedoc` lifts the ceiling. | +| `website` `typescript` | **`6.0.3`**, behind the root's TypeScript 7, because `typedoc@0.28` peers on `<= 6.0.x`. TypeDoc compiles the built `packages/agent-bundle/dist` declarations with it, while Twoslash compiles documentation samples against package source. Re-pin chores check whether a newer `typedoc` lifts the ceiling. | Application code imports the npm package. Never import from `repos/**`. diff --git a/website/rspress.config.ts b/website/rspress.config.ts index 8192420c9..f3bfa954d 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -15,16 +15,14 @@ const docsDir = path.join(websiteDir, 'docs'); const repoRoot = path.join(websiteDir, '..'); const packageSource = path.join(repoRoot, 'packages', 'agent-bundle', 'src'); -const typedocTsconfigPath = path.join(websiteDir, 'tsconfig.typedoc.json'); +const packageDeclarations = path.join(repoRoot, 'packages', 'agent-bundle', 'dist'); +const twoslashTsconfigPath = path.join(websiteDir, 'tsconfig.twoslash.json'); /** - * The `paths` that resolve the workspace packages `packages/agent-bundle/src` + * The `paths` that resolve the workspace packages which documentation samples * imports (`@agent-bundle/runtime`, `rsc-markdown-stream`) to their sources. - * Their published declarations only exist after `pnpm build`, which the docs - * build never runs, so a compiler that resolves them through `package.json` - * types every one of those imports as `any`. TypeDoc reads the tsconfig - * itself; twoslash takes its compiler options programmatically, so the same - * map is read here rather than copied. + * Twoslash takes its compiler options programmatically, so the map is read + * from its dedicated tsconfig rather than copied. * * The file is JSONC and the values are relative to the tsconfig, which is how * TypeScript resolves them; twoslash gets no `baseUrl` or config directory, @@ -61,26 +59,26 @@ const twoslashPaths: Record = { 'agent-bundle/config': [path.join(packageSource, 'config/index.ts')], 'agent-bundle/test': [path.join(packageSource, 'test/index.ts')], 'agent-bundle/eval': [path.join(packageSource, 'eval/index.ts')], - ...readSourceMappedPaths(typedocTsconfigPath), + ...readSourceMappedPaths(twoslashTsconfigPath), }; const publicApiEntryPoints = [ - 'index.ts', - 'api.ts', - 'app/index.ts', - 'cli-entry.ts', - 'config/index.ts', - 'eval/index.ts', - 'launch-env.ts', - 'mcp-apps.ts', - 'meta.ts', - 'mcp-entry.ts', - 'routes/public.ts', - 'rstest/index.ts', - 'test/index.ts', - 'test/browser.ts', - 'web-host.ts', -].map(entry => path.join(packageSource, entry)); + 'index.d.ts', + 'api.d.ts', + 'app.d.ts', + 'cli-entry.d.ts', + 'config/index.d.ts', + 'eval/index.d.ts', + 'launch-env.d.ts', + 'mcp-apps.d.ts', + 'meta.d.ts', + 'mcp-entry.d.ts', + 'routes/public.d.ts', + 'rstest/index.d.ts', + 'test/index.d.ts', + 'test/browser.d.ts', + 'web-host.d.ts', +].map(entry => path.join(packageDeclarations, entry)); const generatedApiDir = 'en/api'; const mirroredApiTargets = [ @@ -192,11 +190,10 @@ export default defineConfig({ }, }, plugins: [ - // TypeDoc and twoslash compile packages/agent-bundle/src with the - // `typescript` pinned in website/package.json. That pin is TypeScript 6 - // because typedoc 0.28 peers on `<= 6.0.x` while the repo root is on - // TypeScript 7, so TS7-only syntax in the package fails here first, with - // an error that names the docsite rather than the cause. + // TypeDoc reads packages/agent-bundle/dist declarations produced by the + // package build. Twoslash still compiles documentation samples against + // package sources. Both use the TypeScript 6 pin in website/package.json + // because typedoc 0.28 peers on `<= 6.0.x`. pluginTypeDoc({ entryPoints: publicApiEntryPoints, outDir: generatedApiDir, diff --git a/website/tsconfig.twoslash.json b/website/tsconfig.twoslash.json new file mode 100644 index 000000000..6a5e4ecf9 --- /dev/null +++ b/website/tsconfig.twoslash.json @@ -0,0 +1,24 @@ +{ + // Twoslash compiles documentation samples against package source so editor + // examples do not depend on the declarations TypeDoc consumes. + "extends": "../packages/agent-bundle/tsconfig.build.json", + "compilerOptions": { + "rootDir": "../packages", + "paths": { + "@agent-bundle/runtime": ["../packages/rsc-runtime/src/index.ts"], + "@agent-bundle/runtime/plugin": ["../packages/rsc-runtime/src/plugin.ts"], + "@agent-bundle/runtime/flight/server": ["../packages/rsc-runtime/src/flight/server.ts"], + "@agent-bundle/runtime/lineage": ["../packages/rsc-runtime/src/lineage/index.ts"], + "@agent-bundle/runtime/mount": ["../packages/rsc-runtime/src/mount/index.ts"], + "@agent-bundle/runtime/notices": ["../packages/rsc-runtime/src/notices/index.ts"], + "@agent-bundle/runtime/notices/inbox-route": ["../packages/rsc-runtime/src/notices/inbox-route.ts"], + "@agent-bundle/runtime/state": ["../packages/rsc-runtime/src/state/index.ts"], + "@agent-bundle/runtime/state/sqlite": ["../packages/rsc-runtime/src/state/sqlite.ts"], + "rsc-markdown-stream": ["../packages/rsc-markdown-stream/src/index.d.ts"] + } + }, + "include": [ + "../packages/agent-bundle/src/**/*.ts", + "../packages/rsc-runtime/src/react-server-dom-rspack.d.ts" + ] +} diff --git a/website/tsconfig.typedoc.json b/website/tsconfig.typedoc.json index c7ddc4897..f19aff6fb 100644 --- a/website/tsconfig.typedoc.json +++ b/website/tsconfig.typedoc.json @@ -1,38 +1,7 @@ { - // TypeDoc consumes the package's own build settings, but the documentation - // build must not depend on `pnpm build`: `packages/agent-bundle/src` imports - // `@agent-bundle/runtime`, whose declarations only exist after that package - // is compiled. Resolving those specifiers to `packages/rsc-runtime/src` - // keeps `pnpm docs:site:build` self-sufficient. The runtime's own workspace - // dependency, `rsc-markdown-stream`, publishes its declarations from `dist` - // too, so it resolves to the hand-written source declaration file. - // - // `paths` is also the source of truth for the Twoslash plugin: - // `rspress.config.ts` reads this file and passes the same map (made - // absolute) as twoslash `compilerOptions.paths`, so a new source-mapped - // specifier is added here once and both compilers see it. - "extends": "../packages/agent-bundle/tsconfig.build.json", - "compilerOptions": { - // Widened from the package's `src` so the resolved runtime sources are - // allowed in the program. TypeDoc never emits, so this only affects checks. - "rootDir": "../packages", - "paths": { - "@agent-bundle/runtime": ["../packages/rsc-runtime/src/index.ts"], - "@agent-bundle/runtime/plugin": ["../packages/rsc-runtime/src/plugin.ts"], - "@agent-bundle/runtime/flight/server": ["../packages/rsc-runtime/src/flight/server.ts"], - "@agent-bundle/runtime/lineage": ["../packages/rsc-runtime/src/lineage/index.ts"], - "@agent-bundle/runtime/mount": ["../packages/rsc-runtime/src/mount/index.ts"], - "@agent-bundle/runtime/notices": ["../packages/rsc-runtime/src/notices/index.ts"], - "@agent-bundle/runtime/notices/inbox-route": ["../packages/rsc-runtime/src/notices/inbox-route.ts"], - "@agent-bundle/runtime/state": ["../packages/rsc-runtime/src/state/index.ts"], - "@agent-bundle/runtime/state/sqlite": ["../packages/rsc-runtime/src/state/sqlite.ts"], - "rsc-markdown-stream": ["../packages/rsc-markdown-stream/src/index.d.ts"] - } - }, - "include": [ - "../packages/agent-bundle/src/**/*.ts", - // Ambient declarations for `react-server-dom-rspack`, which the runtime - // sources rely on and which an import graph alone will not pull in. - "../packages/rsc-runtime/src/react-server-dom-rspack.d.ts" - ] + // TypeDoc documents the declarations users consume, not package sources. + // Run the package build before the documentation build so this tree is + // present and current. + "extends": "../tsconfig.base.json", + "include": ["../packages/agent-bundle/dist/**/*.d.ts"] } From e3e7e48e60fd76ba2371366c5657510a8247bebd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:59:35 +0000 Subject: [PATCH 03/10] feat(workbench): content-hash production assets for immutable caching Ship hashed JS/CSS/assets so the foreground can cache them for a year, while index.html and other unhashed files stay no-store. Contributor HMR keeps stable unhashed names. --- .../agent-bundle/src/dev/foreground-server.ts | 6 +- .../agent-bundle/src/dev/workbench-assets.ts | 8 ++ .../tests/dev-workbench-packaging.test.ts | 13 ++- .../tests/workbench-asset-cache.test.ts | 88 +++++++++++++++++++ packages/workbench/rsbuild.config.ts | 34 ++++--- .../tests/contributor-hmr.e2e.test.ts | 2 +- .../workbench/tests/rsbuild-workbench.test.ts | 47 +++++++++- .../tests/support/workbench-fixture-config.ts | 8 +- 8 files changed, 182 insertions(+), 24 deletions(-) create mode 100644 packages/agent-bundle/tests/workbench-asset-cache.test.ts diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 023bf7fd3..111eece8d 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -29,6 +29,7 @@ import { RouteManifestRoutes, type RouteManifestRouteService } from './routes/ro import { SkillDocumentError, type SkillDocumentService } from './skill-document-service.ts'; import type { Invalidation, ProjectEventMessage, ProjectStatus } from './types.ts'; import { WebHostRoutes, type WebHostEpochSource, type WebHostLaunchOptions } from './web-host-routes.ts'; +import { workbenchAssetCacheControl } from './workbench-assets.ts'; import { isWorkbenchShellPath } from './workbench-shell-paths.ts'; import { diagnostic, @@ -1058,7 +1059,10 @@ export class ForegroundServer { : decodedAssetPath(request.url); const asset = await this.#assets?.read(path); if (asset === undefined) return responseDiagnostic(response, diagnostic('AB8007', 'Route was not found.', 404)); - response.writeHead(200, { 'content-type': asset.contentType }); + response.writeHead(200, { + 'cache-control': workbenchAssetCacheControl(path), + 'content-type': asset.contentType, + }); response.end(method === 'HEAD' ? undefined : asset.body); } } diff --git a/packages/agent-bundle/src/dev/workbench-assets.ts b/packages/agent-bundle/src/dev/workbench-assets.ts index 0cc20caab..75d3c93c5 100644 --- a/packages/agent-bundle/src/dev/workbench-assets.ts +++ b/packages/agent-bundle/src/dev/workbench-assets.ts @@ -41,6 +41,14 @@ const defaultRoot = (): string => resolve(packageRoot, 'dist', 'workbench'); const contentTypeFor = (path: string): string => contentTypes[extname(path).toLowerCase()] ?? 'application/octet-stream'; +const contentHashedAsset = /(?:^|\/)[^/]+\.[a-f0-9]{8}(?:\.[^./]+)+$/iu; + +export const workbenchDocumentCacheControl = 'no-store'; +export const workbenchHashedAssetCacheControl = 'public, max-age=31536000, immutable'; + +export const workbenchAssetCacheControl = (servedPath: string): string => + contentHashedAsset.test(servedPath) ? workbenchHashedAssetCacheControl : workbenchDocumentCacheControl; + /** * Reads only regular files from the fixed prebuilt workbench tree. The HTTP * server already rejects malformed request paths; this source makes direct diff --git a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts index 1705ba166..376027bf6 100644 --- a/packages/agent-bundle/tests/dev-workbench-packaging.test.ts +++ b/packages/agent-bundle/tests/dev-workbench-packaging.test.ts @@ -23,11 +23,17 @@ const buildPackage = async (): Promise => { }; describe.sequential('workbench package build', () => { -it('copies stable prebuilt workbench assets and the exact app-renderer license into the package distribution', async () => { +const hashedWorkbenchBundle = (kind: 'css' | 'js'): RegExp => + kind === 'css' ? /^index\.[a-f0-9]{8}\.css$/u : /^index\.[a-f0-9]{8}\.js$/u; + +it('copies content-hashed prebuilt workbench assets and the exact app-renderer license into the package distribution', async () => { await buildPackage(); await expect(access(join(packageRoot, 'dist', 'workbench', 'index.html'))).resolves.toBeUndefined(); - await expect(readFile(join(packageRoot, 'dist', 'workbench', 'static', 'js', 'index.js'), 'utf8')).resolves.toContain('Workbench navigation'); + const jsRoot = join(packageRoot, 'dist', 'workbench', 'static', 'js'); + const hashedJs = (await readdir(jsRoot)).find((name) => hashedWorkbenchBundle('js').test(name)); + if (hashedJs === undefined) throw new Error('Expected a content-hashed workbench index.js.'); + await expect(readFile(join(jsRoot, hashedJs), 'utf8')).resolves.toContain('Workbench navigation'); await expect(readFile(join(packageRoot, 'dist', 'workbench', 'THIRD_PARTY_NOTICES'), 'utf8')).resolves.toContain('MCP Inspector'); await expect(readFile(join(packageRoot, 'dist', 'workbench', appRendererLicense), 'utf8')).resolves.toBe( await readFile(join(workbenchRoot, appRendererLicense), 'utf8'), @@ -71,7 +77,8 @@ it('serves prebuilt workbench assets from an installed tarball without the repos expect(listing.stdout).toContain('package/dist/workbench/THIRD_PARTY_NOTICES'); expect(listing.stdout).toContain('package/dist/workbench/src/mcp/APP-RENDERER-LICENSE'); expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*\.map$/mu); - expect(listing.stdout).not.toMatch(/package\/dist\/workbench\/.*-[a-f0-9]{8,}/iu); + expect(listing.stdout).toMatch(/package\/dist\/workbench\/static\/js\/index\.[a-f0-9]{8}\.js$/mu); + expect(listing.stdout).toMatch(/package\/dist\/workbench\/static\/css\/index\.[a-f0-9]{8}\.css$/mu); await writeFile(join(consumer, 'package.json'), '{"type":"module"}\n'); await execFile('npm', ['install', ...cachedNpmInstallArguments, tarball], { cwd: consumer, env: installedEnvironment() }); diff --git a/packages/agent-bundle/tests/workbench-asset-cache.test.ts b/packages/agent-bundle/tests/workbench-asset-cache.test.ts new file mode 100644 index 000000000..661ee0ac5 --- /dev/null +++ b/packages/agent-bundle/tests/workbench-asset-cache.test.ts @@ -0,0 +1,88 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { ProjectEventHub } from '../src/dev/events.ts'; +import { startForegroundServer, type ForegroundCoordinator } from '../src/dev/foreground-server.ts'; +import type { Invalidation, ProjectStatus } from '../src/dev/types.ts'; +import { + createWorkbenchAssetSource, + workbenchAssetCacheControl, + workbenchDocumentCacheControl, + workbenchHashedAssetCacheControl, +} from '../src/dev/workbench-assets.ts'; + +const status = (): ProjectStatus => ({ + artifact: { state: 'missing' }, + build: { state: 'idle' }, + source: { diagnostics: [], state: 'unknown' }, +}); + +const coordinator: ForegroundCoordinator = { + close: async () => undefined, + rebuild: async (_invalidation: Invalidation) => undefined, + start: async () => undefined, + status, +}; + +it('treats only content-hashed workbench filenames as immutable', () => { + expect(workbenchAssetCacheControl('index.html')).toBe(workbenchDocumentCacheControl); + expect(workbenchAssetCacheControl('THIRD_PARTY_NOTICES')).toBe(workbenchDocumentCacheControl); + expect(workbenchAssetCacheControl('src/mcp/APP-RENDERER-LICENSE')).toBe(workbenchDocumentCacheControl); + expect(workbenchAssetCacheControl('static/js/index.js')).toBe(workbenchDocumentCacheControl); + expect(workbenchAssetCacheControl('static/js/index.deadbeef.js')).toBe(workbenchHashedAssetCacheControl); + expect(workbenchAssetCacheControl('static/css/index.feedface.css')).toBe(workbenchHashedAssetCacheControl); + expect(workbenchAssetCacheControl('static/image/logo.abcdef12.png')).toBe(workbenchHashedAssetCacheControl); + expect(workbenchDocumentCacheControl).toBe('no-store'); + expect(workbenchHashedAssetCacheControl).toBe('public, max-age=31536000, immutable'); +}); + +it('serves hashed workbench assets as immutable and documents as no-store', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-asset-cache-')); + await Promise.all([ + mkdir(join(root, 'static', 'js'), { recursive: true }), + mkdir(join(root, 'static', 'css'), { recursive: true }), + mkdir(join(root, 'static', 'image'), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(root, 'index.html'), 'Workbench'), + writeFile(join(root, 'THIRD_PARTY_NOTICES'), 'notice\n'), + writeFile(join(root, 'static', 'js', 'index.js'), 'export {};\n'), + writeFile(join(root, 'static', 'js', 'index.deadbeef.js'), 'export {};\n'), + writeFile(join(root, 'static', 'css', 'index.feedface.css'), 'body{}\n'), + writeFile(join(root, 'static', 'image', 'logo.abcdef12.png'), 'png\n'), + ]); + const server = await startForegroundServer({ + assets: createWorkbenchAssetSource({ root }), + coordinator, + eventHub: new ProjectEventHub(), + port: 0, + sessionToken: 'test-session-token', + }); + try { + const [document, shell, hashedJs, hashedCss, hashedImage, unhashedJs, notices] = await Promise.all([ + fetch(server.url), + fetch(`${server.url}/routes/mcp/curator/tool/search`), + fetch(`${server.url}/static/js/index.deadbeef.js`), + fetch(`${server.url}/static/css/index.feedface.css`), + fetch(`${server.url}/static/image/logo.abcdef12.png`), + fetch(`${server.url}/static/js/index.js`), + fetch(`${server.url}/THIRD_PARTY_NOTICES`), + ]); + expect(document.status).toBe(200); + expect(document.headers.get('cache-control')).toBe('no-store'); + expect(shell.status).toBe(200); + expect(shell.headers.get('cache-control')).toBe('no-store'); + expect(await shell.text()).toContain('Workbench'); + expect(hashedJs.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(hashedCss.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(hashedImage.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + expect(unhashedJs.headers.get('cache-control')).toBe('no-store'); + expect(notices.headers.get('cache-control')).toBe('no-store'); + } finally { + await server.close(); + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/workbench/rsbuild.config.ts b/packages/workbench/rsbuild.config.ts index 27cca975d..8bad0ff28 100644 --- a/packages/workbench/rsbuild.config.ts +++ b/packages/workbench/rsbuild.config.ts @@ -13,7 +13,10 @@ const sourceRoot = resolve(import.meta.dirname, 'src'); * so the foreground must allowlist this dev origin; `strictPort` fails loudly * on a busy port instead of silently moving the UI to one it has not allowed. */ -export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_WORKBENCH_API_PROXY) => ({ +export const createWorkbenchConfig = ( + apiProxyTarget = process.env.AGENT_BUNDLE_WORKBENCH_API_PROXY, + mode: 'development' | 'production' = 'production', +) => ({ html: { template: resolve(import.meta.dirname, 'index.html'), }, @@ -26,12 +29,18 @@ export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_ distPath: { root: 'dist', }, - filenameHash: false, - filename: { - assets: '[name][ext]', - css: '[name].css', - js: '[name].js', - }, + filenameHash: mode === 'production', + filename: mode === 'production' + ? { + assets: '[name].[contenthash:8][ext]', + css: '[name].[contenthash:8].css', + js: '[name].[contenthash:8].js', + } + : { + assets: '[name][ext]', + css: '[name].css', + js: '[name].js', + }, }, plugins: [pluginReact()], root: import.meta.dirname, @@ -55,7 +64,10 @@ export const createWorkbenchConfig = (apiProxyTarget = process.env.AGENT_BUNDLE_ // runner), which disables the NODE_ENV define and minification and ships a // bundle that crashes in the browser with "process is not defined". Pinning // the mode to the CLI command keeps builds hermetic regardless of caller env. -export default defineConfig(({ command }) => ({ - mode: command === 'dev' ? ('development' as const) : ('production' as const), - ...createWorkbenchConfig(), -})); +export default defineConfig(({ command }) => { + const mode = command === 'dev' ? ('development' as const) : ('production' as const); + return { + mode, + ...createWorkbenchConfig(undefined, mode), + }; +}); diff --git a/packages/workbench/tests/contributor-hmr.e2e.test.ts b/packages/workbench/tests/contributor-hmr.e2e.test.ts index 98a6a5eba..2c725e86d 100644 --- a/packages/workbench/tests/contributor-hmr.e2e.test.ts +++ b/packages/workbench/tests/contributor-hmr.e2e.test.ts @@ -94,7 +94,7 @@ const startContributorLoop = async (project: ProjectFixture): Promise { +it('hashes production JS, CSS, and assets so the foreground can cache them immutably', () => { expect(resolveConfig('build')).toMatchObject({ + mode: 'production', output: { copy: [ { from: join(workbenchRoot, 'THIRD_PARTY_NOTICES'), to: 'THIRD_PARTY_NOTICES', toType: 'file' }, { from: join(workbenchRoot, 'src/mcp/APP-RENDERER-LICENSE'), to: 'src/mcp/APP-RENDERER-LICENSE', toType: 'file' }, ], + filenameHash: true, + filename: { + assets: '[name].[contenthash:8][ext]', + css: '[name].[contenthash:8].css', + js: '[name].[contenthash:8].js', + }, + }, + }); +}); + +it('keeps contributor HMR filenames unhashed', () => { + expect(resolveConfig('dev')).toMatchObject({ + mode: 'development', + output: { filenameHash: false, filename: { + assets: '[name][ext]', + css: '[name].css', + js: '[name].js', + }, + }, + }); + expect(createWorkbenchConfig(undefined, 'development')).toMatchObject({ + output: { + filenameHash: false, + filename: { + assets: '[name][ext]', css: '[name].css', js: '[name].js', }, @@ -63,13 +89,20 @@ it('configures no dev server block when no foreground target is supplied', () => } }); +const hashedBundleName = (kind: 'css' | 'js'): RegExp => + kind === 'css' ? /^index\.[a-f0-9]{8}\.css$/u : /^index\.[a-f0-9]{8}\.js$/u; + it('publishes the workbench application at the foreground server index asset', async () => { + const jsRoot = join(workbenchRoot, 'dist', 'static', 'js'); await expect(access(join(workbenchRoot, 'dist', 'index.html'))).resolves.toBeUndefined(); - await expect(access(join(workbenchRoot, 'dist', 'static', 'js', 'index.js'))).resolves.toBeUndefined(); + const js = (await readdir(jsRoot)).filter((name) => name.endsWith('.js')); + expect(js.some((name) => hashedBundleName('js').test(name))).toBe(true); + expect(js).not.toContain('index.js'); }); -it('emits browser-safe JS from the prepared production build', async () => { +it('emits browser-safe content-hashed JS that index.html references', async () => { const jsRoot = join(workbenchRoot, 'dist', 'static', 'js'); + const cssRoot = join(workbenchRoot, 'dist', 'static', 'css'); const files = await readdir(jsRoot); const contents = await Promise.all( files.filter((name) => name.endsWith('.js')).map(async (name) => ({ @@ -84,5 +117,11 @@ it('emits browser-safe JS from the prepared production build', async () => { const indexHtml = await readFile(join(workbenchRoot, 'dist', 'index.html'), 'utf8'); expect(indexHtml).toContain('id="root"'); - expect(indexHtml).toContain('/static/js/index.js'); + const referencedJs = files.filter((name) => indexHtml.includes(`/static/js/${name}`)); + expect(referencedJs.length).toBeGreaterThan(0); + expect(referencedJs.every((name) => /\.[a-f0-9]{8}\.js$/u.test(name))).toBe(true); + expect(referencedJs.some((name) => hashedBundleName('js').test(name))).toBe(true); + const css = (await readdir(cssRoot)).filter((name) => name.endsWith('.css')); + const referencedCss = css.filter((name) => indexHtml.includes(`/static/css/${name}`)); + expect(referencedCss).toEqual(expect.arrayContaining([expect.stringMatching(hashedBundleName('css'))])); }); diff --git a/packages/workbench/tests/support/workbench-fixture-config.ts b/packages/workbench/tests/support/workbench-fixture-config.ts index 0706c529f..3d522a376 100644 --- a/packages/workbench/tests/support/workbench-fixture-config.ts +++ b/packages/workbench/tests/support/workbench-fixture-config.ts @@ -43,10 +43,10 @@ const workbenchBrowserAliases = { * carry no manual `source.define` copy. * - `pluginReact()` and `workbenchBrowserAliases`. * - A flat, unhashed `assets/` layout (`.html`, `assets/.js`, - * `assets/.css`). It differs from the production `static/` tree on - * purpose: it is the layout the fixtures have always emitted and their - * servers have always served, and `cleanDistPath: false` because the dist is - * a fresh `mkdtemp` child the test removes itself. + * `assets/.css`). Production emits a content-hashed `static/` tree; + * fixtures keep the layout their servers have always served, and + * `cleanDistPath: false` because the dist is a fresh `mkdtemp` child the + * test removes itself. * * Anything a fixture needs beyond this is an explicit, typed option here — a * test never mutates the returned config. From 7c14dd8bee33654cdec3bbbc574ff84480c9fe1e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:57:36 +0000 Subject: [PATCH 04/10] build(workbench): target Chromium 120 --- packages/workbench/rsbuild.config.ts | 3 +++ packages/workbench/tests/rsbuild-workbench.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/workbench/rsbuild.config.ts b/packages/workbench/rsbuild.config.ts index 8bad0ff28..38acb747a 100644 --- a/packages/workbench/rsbuild.config.ts +++ b/packages/workbench/rsbuild.config.ts @@ -22,6 +22,9 @@ export const createWorkbenchConfig = ( }, output: { assetPrefix: '/', + // Desktop floor: Electron 28 embeds Chromium 120. + // https://releases.electronjs.org/release/v28.0.0 + overrideBrowserslist: ['chrome >= 120'], copy: [ { from: resolve(import.meta.dirname, 'THIRD_PARTY_NOTICES'), to: 'THIRD_PARTY_NOTICES', toType: 'file' }, { from: resolve(sourceRoot, 'mcp', 'APP-RENDERER-LICENSE'), to: 'src/mcp/APP-RENDERER-LICENSE', toType: 'file' }, diff --git a/packages/workbench/tests/rsbuild-workbench.test.ts b/packages/workbench/tests/rsbuild-workbench.test.ts index 55eae3892..73e89184e 100644 --- a/packages/workbench/tests/rsbuild-workbench.test.ts +++ b/packages/workbench/tests/rsbuild-workbench.test.ts @@ -21,6 +21,16 @@ it('pins production mode for builds so ambient NODE_ENV cannot select mode none' expect(resolveConfig('dev')).toMatchObject({ mode: 'development' }); }); +it('targets the desktop Chromium floor in production and development', () => { + for (const command of ['build', 'dev'] as const) { + expect(resolveConfig(command)).toMatchObject({ + output: { + overrideBrowserslist: ['chrome >= 120'], + }, + }); + } +}); + it('hashes production JS, CSS, and assets so the foreground can cache them immutably', () => { expect(resolveConfig('build')).toMatchObject({ mode: 'production', From ab2890ac3883a375e8d2a8db52e7f8e4878cddd4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:59:30 +0000 Subject: [PATCH 05/10] fix(dev): serve extensionless Workbench notice files as text/plain The prebuilt Workbench tree ships THIRD_PARTY_NOTICES and src/mcp/APP-RENDERER-LICENSE without an extension, so the asset source fell through to application/octet-stream and browsers downloaded them instead of rendering. Extensionless files whose basename is a conventional notice name (LICENSE, NOTICE, COPYING, and -/_ prefixed variants) are now typed text/plain; charset=utf-8. Other extensionless files keep the binary fallback and asset path containment is unchanged. --- .../agent-bundle/src/dev/workbench-assets.ts | 12 ++- .../agent-bundle/tests/dev-workbench.test.ts | 93 ++++++++++++++++++- 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/src/dev/workbench-assets.ts b/packages/agent-bundle/src/dev/workbench-assets.ts index 75d3c93c5..bca351507 100644 --- a/packages/agent-bundle/src/dev/workbench-assets.ts +++ b/packages/agent-bundle/src/dev/workbench-assets.ts @@ -39,7 +39,17 @@ const packageRoot = basename(import.meta.dirname) === 'dist' const defaultRoot = (): string => resolve(packageRoot, 'dist', 'workbench'); -const contentTypeFor = (path: string): string => contentTypes[extname(path).toLowerCase()] ?? 'application/octet-stream'; +// The Workbench build copies its attribution files into the asset tree without +// an extension (`THIRD_PARTY_NOTICES`, `src/mcp/APP-RENDERER-LICENSE`). Those +// conventional names are plain text a browser should render; every other +// extensionless file keeps the binary fallback. +const noticeFileName = /^(?:[a-z0-9]+[-_])*(?:licen[cs]e|notices?|copying)$/iu; + +const contentTypeFor = (path: string): string => { + const extension = extname(path).toLowerCase(); + if (extension === '') return noticeFileName.test(basename(path)) ? 'text/plain; charset=utf-8' : 'application/octet-stream'; + return contentTypes[extension] ?? 'application/octet-stream'; +}; const contentHashedAsset = /(?:^|\/)[^/]+\.[a-f0-9]{8}(?:\.[^./]+)+$/iu; diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 0be4a79ac..4e3667d18 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -1,5 +1,5 @@ import { access, mkdtemp, mkdir, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; -import { get as httpGet } from 'node:http'; +import { get as httpGet, request as httpRequest } from 'node:http'; import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; @@ -294,6 +294,97 @@ it('contains prebuilt workbench asset reads to their declared root', async () => } }); +it('types extensionless notice and license files as text and keeps the binary fallback for other extensionless files', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-notices-')); + try { + await Promise.all([mkdir(join(root, 'src', 'mcp'), { recursive: true }), mkdir(join(root, 'static'), { recursive: true })]); + await Promise.all([ + writeFile(join(root, 'THIRD_PARTY_NOTICES'), 'Agent Bundle workbench includes an MCP App renderer.\n'), + writeFile(join(root, 'src', 'mcp', 'APP-RENDERER-LICENSE'), 'MIT License\n'), + writeFile(join(root, 'LICENSE'), 'Apache License\n'), + writeFile(join(root, 'NOTICE'), 'Agent Bundle\n'), + writeFile(join(root, 'static', 'payload'), Buffer.from([0x00, 0xff, 0x10, 0x80])), + writeFile(join(root, 'static', 'licensed-fixture'), 'not a notice\n'), + ]); + const assets = createWorkbenchAssetSource({ root }); + + const text = { contentType: 'text/plain; charset=utf-8' }; + await expect(assets.read('THIRD_PARTY_NOTICES')).resolves.toMatchObject(text); + await expect(assets.read('src/mcp/APP-RENDERER-LICENSE')).resolves.toMatchObject(text); + await expect(assets.read('LICENSE')).resolves.toMatchObject(text); + await expect(assets.read('NOTICE')).resolves.toMatchObject(text); + await expect(assets.read('static/payload')).resolves.toMatchObject({ contentType: 'application/octet-stream' }); + await expect(assets.read('static/licensed-fixture')).resolves.toMatchObject({ contentType: 'application/octet-stream' }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('serves packaged notice files over HTTP as text without loosening asset path checks', async () => { + const project = await createProjectFixture(); + const assetsRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-notice-server-')); + await Promise.all([mkdir(join(assetsRoot, 'src', 'mcp'), { recursive: true }), mkdir(join(assetsRoot, 'static'), { recursive: true })]); + await Promise.all([ + writeFile(join(assetsRoot, 'index.html'), 'Agent Bundle workbench'), + writeFile(join(assetsRoot, 'THIRD_PARTY_NOTICES'), 'Agent Bundle workbench includes an MCP App renderer.\n'), + writeFile(join(assetsRoot, 'src', 'mcp', 'APP-RENDERER-LICENSE'), 'MIT License\n'), + writeFile(join(assetsRoot, 'static', 'payload'), Buffer.from([0x00, 0xff, 0x10, 0x80])), + ]); + try { + const server = await startDevServer({ + assets: createWorkbenchAssetSource({ root: assetsRoot }), + open: false, + openBrowser: async () => {}, + port: 0, + root: project.root, + }); + // `fetch` collapses `%2e%2e` into `..` before the request leaves, so the + // traversal probes go over `node:http`, which sends the path verbatim. + const address = new URL(server.url); + const probe = (path: string, method: 'GET' | 'HEAD' = 'GET'): Promise> => new Promise((resolvePromise, rejectPromise) => { + const request = httpRequest({ host: address.hostname, method, path, port: address.port }, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + body += chunk; + }); + response.once('end', () => resolvePromise({ body, contentType: response.headers['content-type'], status: response.statusCode ?? 0 })); + response.once('error', rejectPromise); + }); + request.once('error', rejectPromise); + request.end(); + }); + + await expect(probe('/THIRD_PARTY_NOTICES')).resolves.toEqual({ + body: 'Agent Bundle workbench includes an MCP App renderer.\n', + contentType: 'text/plain; charset=utf-8', + status: 200, + }); + await expect(probe('/src/mcp/APP-RENDERER-LICENSE')).resolves.toEqual({ + body: 'MIT License\n', + contentType: 'text/plain; charset=utf-8', + status: 200, + }); + await expect(probe('/src/mcp/APP-RENDERER-LICENSE', 'HEAD')).resolves.toEqual({ + body: '', + contentType: 'text/plain; charset=utf-8', + status: 200, + }); + await expect(probe('/static/payload')).resolves.toMatchObject({ contentType: 'application/octet-stream', status: 200 }); + await expect(probe('/src/mcp/%2e%2e/%2e%2e/THIRD_PARTY_NOTICES')).resolves.toMatchObject({ status: 400 }); + await expect(probe('/src/mcp/APP-RENDERER-LICENSE%00')).resolves.toMatchObject({ status: 400 }); + await expect(probe('/src/mcp')).resolves.toMatchObject({ status: 404 }); + await expect(probe('/LICENSE')).resolves.toMatchObject({ status: 404 }); + await server.close(); + } finally { + await Promise.all([removeProjectFixture(project.root), rm(assetsRoot, { force: true, recursive: true })]); + } +}, 30_000); + it('starts a loopback server with prebuilt assets, does not open on --no-open, and closes its coordinator', async () => { const project = await createProjectFixture(); const assetsRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-workbench-page-')); From a53b22fcfe18ac8ebf7af240ef4e101d49c00445 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:23:37 +0000 Subject: [PATCH 06/10] chore: add inspection audit changeset --- .changeset/render-lowered-bundler-config.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/render-lowered-bundler-config.md diff --git a/.changeset/render-lowered-bundler-config.md b/.changeset/render-lowered-bundler-config.md new file mode 100644 index 000000000..7c4f3346f --- /dev/null +++ b/.changeset/render-lowered-bundler-config.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": patch +--- + +Make `inspect --bundler` render the lowered Rspack configuration and serve Workbench assets with +production cache and content-type headers. (#566) From dd5bb6606c5fdf4b77583b6f6187280e8dae9677 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:33:51 +0000 Subject: [PATCH 07/10] chore: deslop inspection workbench audit --- packages/agent-bundle/src/api.ts | 5 ++--- packages/agent-bundle/src/build/inspect-bundler.ts | 2 -- packages/agent-bundle/tests/inspect-bundler.test.ts | 3 --- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 48bd80496..436c6cac4 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -26,6 +26,7 @@ import { featureCapabilityName, type AgentComponentKind, } from './core/components.ts'; +import { errorMessage } from './core/errors.ts'; import { isInsideOrEqual } from './core/paths.ts'; import { stateDefinitionProjection, @@ -1153,13 +1154,11 @@ export const inspect = async (options: InspectOptions): Promise = ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); } catch (error) { - // The lowering runs the build's own invariant assertions, so the - // refusal names the hatch value the build would refuse. return invalidInspection(freezeDiagnostics([ ...prepared.diagnostics, projectDiagnostic( 'AB7001', - `Unable to compose the bundler inspection: ${error instanceof Error ? error.message : String(error)}`, + `Unable to compose the bundler inspection: ${errorMessage(error)}`, { sourcePath: prepared.configPath }, ), ])); diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 9208ae566..fce0df992 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -91,7 +91,6 @@ const artifactOutputToken = ''; const isPlainObject: (value: object) => boolean = isPlainRecord; -/** Absolute build-time paths and the stable tokens that stand for them in the rendered configs. */ type PathTokens = readonly (readonly [absolute: string, token: string])[]; const tokenizePath = (value: string, tokens: PathTokens): string => { @@ -330,7 +329,6 @@ const packageBuildEntries = async (model: NormalizedPlugin): Promise loweredConfig(entry).name)).toEqual([ 'agent-bundle-bin-bundler-fixture', 'agent-bundle-index', @@ -206,7 +205,6 @@ it('renders the lowered Rspack configuration of every compiled output with the t const lib = entryOf(entries, 'lib', 'index'); expect(lib.outputPath).toBe('dist/index.js'); - // The declaration tsconfig the package build synthesizes appears as its token. expect(loweredConfig(lib).resolve?.tsConfig).toEqual({ configFile: '', references: 'auto' }); const app = entryOf(entries, 'mcp-app', 'dashboard'); @@ -361,7 +359,6 @@ it('inspects the per-host preflight wrapper under the composite identity', async expect(entry.generatedEntry).toContain('agent-bundle/event-project'); expect(entry.generatedEntry).toContain('.execute.mjs'); expect(entry.generatedEntry).not.toContain('AGENT_BUNDLE_HOOK_HOST'); - // Every wrapper lowers to its own compiler writing its own file into the one root. expect(loweredConfig(entry)).toMatchObject({ name: `agent-bundle-${entry.outputPath.replace(/\.mjs$/u, '').replaceAll('/', '-')}`, output: { filename: '[function jsFilename]', path: '' }, From e741e44e5a6546287064844400e1774ef76fcdbc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:05:41 +0000 Subject: [PATCH 08/10] fix: address inspection audit review --- .changeset/render-lowered-bundler-config.md | 4 +- docs/diagnostics.md | 2 +- .../agent-bundle/src/build/inspect-bundler.ts | 74 +++++++++++-------- .../agent-bundle/src/build/package-build.ts | 2 +- packages/agent-bundle/src/cli.ts | 4 +- .../tests/inspect-bundler.test.ts | 13 +++- .../docs/en/reference/targets-artifacts.mdx | 2 +- .../docs/zh/reference/targets-artifacts.mdx | 2 +- 8 files changed, 63 insertions(+), 40 deletions(-) diff --git a/.changeset/render-lowered-bundler-config.md b/.changeset/render-lowered-bundler-config.md index 7c4f3346f..82763cba5 100644 --- a/.changeset/render-lowered-bundler-config.md +++ b/.changeset/render-lowered-bundler-config.md @@ -2,5 +2,5 @@ "agent-bundle": patch --- -Make `inspect --bundler` render the lowered Rspack configuration and serve Workbench assets with -production cache and content-type headers. (#566) +Add `mcp-app` to `BundlerInspectionEntry.kind`, render lowered bundler configurations, reword +`AB7001`, and serve Workbench assets with production cache and content-type headers. (#566) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 9b8dce366..bf262d7eb 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1871,7 +1871,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | | `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | -| `AB7001` | error | Install/uninstall: the bundle identity is unreadable — no host manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed); a manifest that is not a JSON object or lacks a nonempty required key; a Cursor plugin name that is not a safe local name; a Claude or Codex bundle with no marketplace identity. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — the lowering ran the build's own Rslib/Rsbuild invariant assertions and the reason names the `tools` value the build would refuse (a reserved `agent-bundle/*` alias, a framework runtime module in `externals`). | Install: point `--from` at a built bundle whose host manifest carries the fields the message names. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: remove the refused `tools` value the reason names. | +| `AB7001` | error | Install/uninstall: the bundle identity is unreadable — no host manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed); a manifest that is not a JSON object or lacks a nonempty required key; a Cursor plugin name that is not a safe local name; a Claude or Codex bundle with no marketplace identity. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse (such as a reserved `agent-bundle/*` alias or a framework runtime module in `externals`). | Install: point `--from` at a built bundle whose host manifest carries the fields the message names. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index fce0df992..8044c340b 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -1,4 +1,4 @@ -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { eventIpcRuntimeSpecifier, @@ -30,7 +30,7 @@ import type { CompositePlan } from './compose.ts'; import { eventRuntimeHosting, eventRuntimeModulePath, planCompiledMcpEntries, selectedServerHosts } from './entries.ts'; import { inspectMcpAppsConfig, planCompiledMcpApps } from './mcp-apps.ts'; import { projectMeta } from './meta.ts'; -import { planPackageEntries } from './package-build.ts'; +import { planPackageEntries, synthesizeDtsTsconfig } from './package-build.ts'; import { inspectRslibEntries, type RslibEntry } from './rslib.ts'; import { deepFreeze } from '../core/freeze.ts'; import type { AgentBundleMeta } from '../meta.ts'; @@ -313,11 +313,13 @@ const hookEntries = (entries: readonly TargetHookEntry[], target: string): reado target, })); -const packageBuildEntries = async (model: NormalizedPlugin): Promise => { +const packageBuildEntries = async ( + model: NormalizedPlugin, + dtsTsconfigPath: string | undefined, +): Promise => { const packageBuild = model.packageBuild; if (packageBuild === undefined) return []; - const dtsTsconfig = packageBuild.lib?.dts === true ? generatedDtsTsconfigToken : undefined; - const planned = await planPackageEntries(model, dtsTsconfig); + const planned = await planPackageEntries(model, dtsTsconfigPath); return planned.map((entry) => { const bin = entry.executable; return { @@ -410,42 +412,52 @@ export const composeBundlerInspection = async (options: { }): Promise => { const { composite, model, projectRoot, tools } = options; const meta = projectMeta(model.metadata); + const packageLib = model.packageBuild?.lib; + const dtsTsconfig = packageLib?.dts === true + ? await synthesizeDtsTsconfig({ projectRoot, sourceDir: dirname(packageLib.source) }) + : undefined; // The output roots are absolute, as the build passes them and as the // resolved-config assertions expect them; the rendering folds the token // root back to its token. const artifactOutputRoot = resolve(projectRoot, artifactOutputToken); const tokens: PathTokens = [ [artifactOutputRoot, artifactOutputToken], - [resolve(projectRoot, generatedDtsTsconfigToken), generatedDtsTsconfigToken], - ]; - // The artifact surfaces ride one Rslib run, as the build stages them; the - // package build is its own run with its own output root. - const artifactSurfaces: readonly PlannedRslibInspection[] = [ - ...(composite.cliBin ? cliBinEntries(model, artifactOutputRoot, composite.identity) : []), - ...(await scriptEntries(model, composite)), - ...(await mcpEntryEntries(model, composite, artifactOutputRoot)), - ...hookEntries(composite.hookEntries, composite.identity), - ]; - const entries: BundlerInspectionEntry[] = [ - ...(await loweredRslibEntries(artifactSurfaces, { - meta, - outputRoot: artifactOutputRoot, - projectRoot, - tokens, - ...(tools === undefined ? {} : { tools }), - })), - ...(await mcpAppEntries(model, projectRoot, composite, artifactOutputRoot, tokens, tools)), - ...(model.packageBuild === undefined + ...(dtsTsconfig === undefined ? [] - : await loweredRslibEntries(await packageBuildEntries(model), { + : [[dtsTsconfig.path, generatedDtsTsconfigToken] as const]), + ]; + try { + // The artifact surfaces ride one Rslib run, as the build stages them; the + // package build is its own run with its own output root. + const artifactSurfaces: readonly PlannedRslibInspection[] = [ + ...(composite.cliBin ? cliBinEntries(model, artifactOutputRoot, composite.identity) : []), + ...(await scriptEntries(model, composite)), + ...(await mcpEntryEntries(model, composite, artifactOutputRoot)), + ...hookEntries(composite.hookEntries, composite.identity), + ]; + const entries: BundlerInspectionEntry[] = [ + ...(await loweredRslibEntries(artifactSurfaces, { meta, - outputRoot: resolve(projectRoot, model.packageBuild.outputDir), + outputRoot: artifactOutputRoot, projectRoot, tokens, ...(tools === undefined ? {} : { tools }), })), - ]; - return deepFreeze({ - entries: entries.sort(entryOrder), - }); + ...(await mcpAppEntries(model, projectRoot, composite, artifactOutputRoot, tokens, tools)), + ...(model.packageBuild === undefined + ? [] + : await loweredRslibEntries(await packageBuildEntries(model, dtsTsconfig?.path), { + meta, + outputRoot: resolve(projectRoot, model.packageBuild.outputDir), + projectRoot, + tokens, + ...(tools === undefined ? {} : { tools }), + })), + ]; + return deepFreeze({ + entries: entries.sort(entryOrder), + }); + } finally { + await dtsTsconfig?.cleanup(); + } }; diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index c61d34aaf..b2d2a90be 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -76,7 +76,7 @@ export interface PlannedPackageEntry extends RslibEntry { * package output, exactly like the dedicated build tsconfig consumers * previously maintained by hand. */ -const synthesizeDtsTsconfig = async (options: { +export const synthesizeDtsTsconfig = async (options: { readonly projectRoot: string; readonly sourceDir: string; }): Promise<{ readonly cleanup: () => Promise; readonly path: string }> => { diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index f30574baa..879b419bc 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -516,7 +516,7 @@ const humanInspect = (result: Awaited>): string => { return out.join(''); } if (result.selected?.bundler !== undefined) { - // The bundler focus is a debugging dump: the full synthesized + // The bundler focus is a debugging dump: the full lowered // configuration is the human output, not a one-line summary. out.push(`${JSON.stringify(result.selected.bundler, null, 2)}\n`); return out.join(''); @@ -1025,7 +1025,7 @@ export const runCli = async ( const inspectCommand = configureInspectOptions( program.command('inspect').description('Inspect normalized targets and adapter plans'), ) - .option('--bundler', 'Include the synthesized bundler configuration focus') + .option('--bundler', 'Include the lowered bundler configuration focus') .option('--hooks', 'Include the hook focus') .option('--routes', 'Include the compiled route-graph focus') .option('--skills', 'Include the skill focus') diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index b474f2fdb..e9981bdd1 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -34,6 +34,9 @@ const createProject = async (): Promise => { await Promise.all([ symlink(join(workspaceNodeModules, 'typescript'), join(root, 'node_modules', 'typescript'), 'dir'), writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile(join(root, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { experimentalDecorators: true }, + })), writeFile( join(root, 'agent-bundle.config.ts'), [ @@ -206,6 +209,12 @@ it('renders the lowered Rspack configuration of every compiled output with the t const lib = entryOf(entries, 'lib', 'index'); expect(lib.outputPath).toBe('dist/index.js'); expect(loweredConfig(lib).resolve?.tsConfig).toEqual({ configFile: '', references: 'auto' }); + const legacyDecorators = (entry: BundlerInspectionEntry): boolean => + stableJson(entry.config).includes('"legacyDecorator":true'); + expect(legacyDecorators(lib)).toBe(true); + expect(legacyDecorators(lib)).toBe(legacyDecorators(script)); + expect((await readdir(join(root, 'node_modules'))) + .filter((name) => name.startsWith('.agent-bundle-dts-'))).toEqual([]); const app = entryOf(entries, 'mcp-app', 'dashboard'); expect(app).toMatchObject({ @@ -279,6 +288,8 @@ it('reports a tools hatch the lowering refuses as an invalid inspection naming t code: 'AB7001', message: expect.stringContaining('The tools escape hatch must not alias the reserved specifier matched by "agent-bundle/meta"'), })]); + expect((await readdir(join(root, 'node_modules'))) + .filter((name) => name.startsWith('.agent-bundle-dts-'))).toEqual([]); }); it('inspects the per-host preflight wrapper under the composite identity', async () => { diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index fcb2ffc1c..118001110 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -100,7 +100,7 @@ selected hosts. MCP entries, scripts, routed CLI bins, and MCP Apps are compiled **once** and attributed to the **composite identity** — the selected host names, sorted and joined by `+`, such as `claude+codex` — rather than to any one host. `agent-bundle inspect --bundler` shows the same -thing: its `distPath.root` is the artifact output, ``, with no host segment beneath it. +thing: its `output.path` is the artifact output, ``, with no host segment beneath it. ### One root, one set of bytes diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index 551a63594..5ab6e7bd4 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -89,7 +89,7 @@ wrapper 建立索引。 MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属于**组合身份**——所选宿主名按名称排序、以 `+` 连接,例如 `claude+codex`——而不是归属于任何单个宿主。`agent-bundle inspect --bundler` 显示的是同一 -件事:它的 `distPath.root` 就是产物输出 ``,其下没有宿主段。 +件事:它的 `output.path` 就是产物输出 ``,其下没有宿主段。 ### 一个根目录,一套字节 From d423450239c342735f50d6f8c7829c44daa0ab47 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:17:24 +0000 Subject: [PATCH 09/10] docs: clarify inspection release note --- .changeset/render-lowered-bundler-config.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/render-lowered-bundler-config.md b/.changeset/render-lowered-bundler-config.md index 82763cba5..df543915f 100644 --- a/.changeset/render-lowered-bundler-config.md +++ b/.changeset/render-lowered-bundler-config.md @@ -2,5 +2,7 @@ "agent-bundle": patch --- -Add `mcp-app` to `BundlerInspectionEntry.kind`, render lowered bundler configurations, reword -`AB7001`, and serve Workbench assets with production cache and content-type headers. (#566) +Make `inspect --bundler` render lowered Rspack configurations, rename +`BundlerInspectionEntry.kind` from `mcp-apps` to `mcp-app`, require `source`, surface the lowering +reason in `AB7001`, and serve Workbench assets with production cache and content-type headers. +(#566) From b102b8f14e37b648ab05c4fdd95c8fa95d8a53ed Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:24:33 +0000 Subject: [PATCH 10/10] docs: link inspection changeset to PR --- .changeset/render-lowered-bundler-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/render-lowered-bundler-config.md b/.changeset/render-lowered-bundler-config.md index df543915f..1cab650ba 100644 --- a/.changeset/render-lowered-bundler-config.md +++ b/.changeset/render-lowered-bundler-config.md @@ -5,4 +5,4 @@ Make `inspect --bundler` render lowered Rspack configurations, rename `BundlerInspectionEntry.kind` from `mcp-apps` to `mcp-app`, require `source`, surface the lowering reason in `AB7001`, and serve Workbench assets with production cache and content-type headers. -(#566) +(#649)