diff --git a/.changeset/typed-routes-by-default-and-plugin-collision.md b/.changeset/typed-routes-by-default-and-plugin-collision.md new file mode 100644 index 000000000..74c0a3173 --- /dev/null +++ b/.changeset/typed-routes-by-default-and-plugin-collision.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": patch +"create-agent-bundle": patch +--- + +Make the generated `.agent-bundle/routes.d.ts` part of the TypeScript program by default and reject duplicated framework plugins. `create-agent-bundle` templates list `".agent-bundle/routes.d.ts"` in `tsconfig.json` `include` (the file stays gitignored), so `renderRoute` / `renderRouteEvents` type-check route ids, `input`, and `result` from the first build instead of degrading to `string` / `unknown` until the include is discovered in the docs; `agent-bundle validate` warns with `AB4834` when a project that compiles routes or providers has a root `tsconfig.json` whose program (resolved like `tsc -p`, including `extends` and one level of project `references`) leaves the published declaration out. `agent-bundle validate` (and every diagnostic-gated command) rejects a `tools.rsbuild.plugins` entry whose `name` matches a plugin the framework already registers (`rsbuild:react` from `@rsbuild/plugin-react`) with `AB4724`, because `plugins` arrays concatenate and Rsbuild never dedupes plugins by name, so the plugin would otherwise run twice. (#497) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 981d2f095..d7ffb70de 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -23,13 +23,13 @@ even when no error diagnostic was reported. | `AB46xx` | Assets and the generated-runtime floor. | | `AB470x` | Package build `bin` configuration (`AB4706`: artifact output overlaps `dist`; `AB4707`–`AB4709`: `output.distPath` shape, root escape, reserved namespace). | | `AB471x` | Package build `lib` configuration (`AB4710`–`AB4715`) and declaration generation (`AB4716`; see below). | -| `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. | +| `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch (`AB4720`–`AB4723`: shape; `AB4724`: a framework-owned Rsbuild plugin re-added through `tools.rsbuild.plugins`; see below). | | `AB473x` | Migration nudges (informational; see below). | | `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | | `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | -| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), and provider conventions (see below). | +| `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures. | | `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks (`AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below). | @@ -521,7 +521,38 @@ above, never per feature. Skills keep their own closed per-host schemas | `AB4927` | error | A command explicitly targets a host that supports commands but whose `commands.` row for a frontmatter field the command uses is `degraded`, `unavailable`, or `prohibited` (the message carries the host's reason). Cursor's pinned commands surface is frontmatter-free Markdown, so every field row is unavailable there. | Remove the field or drop that host from the command's `targets`. | | `AB4928` | warning | An implicitly selected host supports commands but cannot express a frontmatter field the command uses; the command ships there without it (Cursor receives the prompt body only). | Accept the omission, restrict the command's `targets` to hosts that support the field, or remove the field. | -## Route graph, state, layout, and provider conventions (`AB4800`–`AB4833`, `AB4940`–`AB4942`) +## The bundler escape hatch (`AB4720`–`AB4724`) + +`tools.rsbuild` and `tools.rspack` are validated with the rest of the config +source, so a malformed or colliding hatch is an **error** before any bundler +runs — in `validate`, `build`, `inspect`, and `dev` alike. `AB4720`–`AB4723` +check the shape: `tools` must be an object whose only keys are `rsbuild` +(an Rsbuild environment-config object) and `rspack` (an Rspack config +object, a mutator function, or an array of both). + +`AB4724` checks `tools.rsbuild.plugins` against the Rsbuild plugins the +framework registers itself — currently `@rsbuild/plugin-react` +(`rsbuild:react`), which every synthesized Rslib entry and every React-syntax +MCP App view carries. The hatch merges *beside* the framework profile +(`mergeRslibConfig` / `mergeRsbuildConfig` concatenate `plugins` arrays), and +Rsbuild's plugin manager appends every plugin it is handed without deduping by +name, so re-adding `pluginReact()` would register it twice. The check is +static: plugin objects are matched by `name`, nested arrays are flattened the +way Rsbuild flattens them, `false`/`null`/`undefined` holes are skipped, and a +plugin supplied as a Promise is not inspected. It is an error rather than a +warning for the same reason as its siblings: a config problem with one +deterministic fix, reported once at the source, so no build ever runs a +framework-owned plugin twice by accident. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB4720` | error | `tools` is not an object. | Declare `tools: { rsbuild?, rspack? }`. | +| `AB4721` | error | `tools` carries a key other than `rsbuild` or `rspack`. | Remove the key; the hatch has exactly two fragments. | +| `AB4722` | error | `tools.rsbuild` is not an Rsbuild environment-config object. | Declare an object fragment. | +| `AB4723` | error | `tools.rspack` is not an Rspack config object, a mutator function, or an array of both. | Use one of the three Rslib `tools.rspack` forms. | +| `AB4724` | error | `tools.rsbuild.plugins` supplies a plugin whose `name` matches a framework-owned registration (`rsbuild:react` from `@rsbuild/plugin-react`). The message names the plugin and its package. | Remove the plugin from `tools.rsbuild.plugins`; agent-bundle registers it in every config it synthesizes. | + +## Route graph, state, layout, and provider conventions (`AB4800`–`AB4834`, `AB4940`–`AB4942`) The route-graph compiler discovers conventional route modules (`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, @@ -606,7 +637,19 @@ order — and augments `@agent-bundle/runtime`'s `AgentProviderValues` so `(await agent()).providers.` observes that type in projects whose TypeScript program includes the file. Provider-free graphs emit no augmentation, so the declaration never references a module the project has no -reason to depend on. +reason to depend on. The file is only as good as the program that compiles +it: `create-agent-bundle` templates and the `examples/*` projects list +`".agent-bundle/routes.d.ts"` in `tsconfig.json` `include` (a literal entry, +because `**/*` never descends into dot-directories), while the file itself +stays gitignored. After publishing the declaration, `agent-bundle validate` +resolves the root `tsconfig.json` program the way `tsc -p` does — `extends`, +`files`, `include`, `exclude`, and one level of `references` for a +solution-style root — and reports `AB4834` (a **warning**, surfaced by +`validate` only) when the published file is not among its root files. A +project with no root `tsconfig.json`, no published declaration (route-free +and provider-free), or a `tsconfig.json` TypeScript cannot parse gets no +diagnostic: there is no program to be missing from, or `tsc` already +reports the parse failure itself. Conventional `src/scripts/` routes ship through the same pipeline as explicit `scripts` entries (#102 stage 1): a plain module directly under @@ -717,6 +760,7 @@ schema constants), unions, nested objects, transforms, coercions — raises | `AB4831` | error | Two layout modules declare one layout scope (for example `src/layout.ts` beside `src/layout.tsx`). Keep exactly one module per scope. | | `AB4832` | error | A server layout (`src/mcp//layout.*`) names an MCP server that declares no tool, resource, or prompt route modules — the server directory is missing or holds only `apps/` routes, which never take a layout. Add routes under that server directory, move the layout, or rename it `_layout.*` to opt out. A server pinned to `custom`, `command`, or `remote` via `routes.servers.` is skipped entirely: its layout is neither validated (`AB4830`) nor retained, because no generated worker composes it. | | `AB4833` | error | `notices.retention` is malformed: `notices` or `retention` is not an object, carries an unknown key, `terminalTtl` is not a positive integer of milliseconds or a duration such as `"7d"`, `"12h"`, `"30m"`, or `"90s"`, `maxTerminal` / `maxJournalBytes` is not a positive integer — or the policy is declared by a project without a conventional `src/state.ts`, which has no co-mounted notice ledger to retain. Omit a field to keep the runtime default (`7d`, `500`, `16777216`). | +| `AB4834` | warning | `agent-bundle validate` published `.agent-bundle/routes.d.ts` (the project compiles routes or providers) but the root `tsconfig.json` program — resolved like `tsc -p`, including `extends` and one level of project `references` — does not compile it, so `renderRoute` / `renderRouteEvents` type-check route ids as `string` and `input` / `result` as `unknown`. Reported on `tsconfig.json`; never for a project without one. | Add `".agent-bundle/routes.d.ts"` to `tsconfig.json` `include` (not `files`: an `include` entry is inert until the first build publishes the file, while a missing `files` entry is a `tsc` error); `build`, `dev`, and `validate` keep the file current and it stays gitignored. | | `AB4940` | error | A conventional provider module has no default export or its default export is not a function. Default-export a factory receiving `{ invocation, signal }`. | | `AB4941` | error | Two provider filenames derive the same camel-cased provider key. Rename one file so every provider key is unique. | | `AB4942` | error | A provider filename derives the reserved `processLifetime` key. Rename the file so its camel-cased key does not collide with the framework-owned provider. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index a0e52fa86..4fa510606 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -987,6 +987,16 @@ and through a post-build scan of the emitted bundle for function-form `externals` — because generated executables must stay self-contained. The hatch customizes *how code compiles*, never *what the artifact promises*. +The hatch merges *beside* the framework profile, not over it: `plugins` +arrays concatenate, and Rsbuild's plugin manager appends every plugin it is +handed without deduping by name. So a `tools.rsbuild.plugins` entry that +re-adds a plugin the framework already registers — `@rsbuild/plugin-react` +(`rsbuild:react`), carried by every synthesized Rslib entry and every +React-syntax MCP App view — would run it twice. `agent-bundle validate` +reports that as `AB4724` (an error, like the other `tools` shape checks) with +the plugin and package name; remove the entry, the framework already +registers it. + The hatch executes under two different bundler engine copies. Artifact scripts, MCP entries, hook wrappers, and the package build compile through Rslib, which runs the Rsbuild/Rspack versions nested inside `@rslib/core` diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 39e2fdf21..a3db335fd 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -148,9 +148,11 @@ The generated `.agent-bundle/routes.d.ts` declares `AgentBundleProviders` (`ProviderKey`, `ProviderValue`) from each factory's resolved return type and augments `@agent-bundle/runtime`'s `AgentProviderValues`, so `(await agent()).providers.library` is a `LibraryContext` with no cast once -the file is part of the project's TypeScript program (add -`".agent-bundle/routes.d.ts"` to `tsconfig.json` `include`). Undeclared keys -stay `unknown`. The `agent-bundle/test` harness (`renderRoute`, `invokeCli`, +the file is part of the project's TypeScript program. `create-agent-bundle` +templates include it by default (`".agent-bundle/routes.d.ts"` in +`tsconfig.json` `include`; the file stays gitignored), and `agent-bundle +validate` warns with `AB4834` when a project that compiles routes or +providers leaves it out. Undeclared keys stay `unknown`. The `agent-bundle/test` harness (`renderRoute`, `invokeCli`, the in-memory MCP helpers) mounts the project's providers automatically, in the same order and with the same fail-closed semantics as the generated request scopes, so a route-unit test observes what the artifact would mount — including diff --git a/examples/audiobook-curator/tests/route-unit/context.test.ts b/examples/audiobook-curator/tests/route-unit/context.test.ts index 87ff71b0b..8b68d534d 100644 --- a/examples/audiobook-curator/tests/route-unit/context.test.ts +++ b/examples/audiobook-curator/tests/route-unit/context.test.ts @@ -1,3 +1,4 @@ +import type { AgentProviderValues } from '@agent-bundle/runtime'; import { expect, it } from '@rstest/core'; import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; @@ -40,8 +41,12 @@ it('renders the catalog from injected library context with its contents envelope it('renders an honest degraded catalog when library context is absent', async () => { // The harness mounts `src/providers/library.ts` automatically, so the // degraded path needs an explicit empty provider map to keep it absent. + // The generated `.agent-bundle/routes.d.ts` (in this project's tsconfig + // program) makes the declared `library` key required, so this deliberate + // contract violation is spelled out as a cast rather than left implicit. + const absentProviders = {} as unknown as AgentProviderValues; const rendered = await renderRoute('resource:curator/catalog', { - context: { providers: {} }, + context: { providers: absentProviders }, input: { uri: 'audiobook-curator://catalog' }, }); const value = rendered.document.value as { diff --git a/examples/audiobook-curator/tsconfig.json b/examples/audiobook-curator/tsconfig.json index 67cbb7fcf..4870552b4 100644 --- a/examples/audiobook-curator/tsconfig.json +++ b/examples/audiobook-curator/tsconfig.json @@ -5,6 +5,7 @@ }, "include": [ "agent-bundle.config.ts", + ".agent-bundle/routes.d.ts", "src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", diff --git a/examples/host-test/tsconfig.json b/examples/host-test/tsconfig.json index 67cbb7fcf..4870552b4 100644 --- a/examples/host-test/tsconfig.json +++ b/examples/host-test/tsconfig.json @@ -5,6 +5,7 @@ }, "include": [ "agent-bundle.config.ts", + ".agent-bundle/routes.d.ts", "src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", diff --git a/examples/mcp-app/tsconfig.json b/examples/mcp-app/tsconfig.json index 77f8330c8..6ec45a609 100644 --- a/examples/mcp-app/tsconfig.json +++ b/examples/mcp-app/tsconfig.json @@ -4,6 +4,7 @@ "jsx": "react-jsx" }, "include": [ + ".agent-bundle/routes.d.ts", "agent-bundle.config.ts", "evals/**/*.ts", "src/**/*.ts", diff --git a/examples/rsc-agent-runtime/tsconfig.json b/examples/rsc-agent-runtime/tsconfig.json index 67cbb7fcf..4870552b4 100644 --- a/examples/rsc-agent-runtime/tsconfig.json +++ b/examples/rsc-agent-runtime/tsconfig.json @@ -5,6 +5,7 @@ }, "include": [ "agent-bundle.config.ts", + ".agent-bundle/routes.d.ts", "src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", diff --git a/examples/worktree-proximity/tests/route-unit/routes.test.ts b/examples/worktree-proximity/tests/route-unit/routes.test.ts index f419cae69..48ba768f2 100644 --- a/examples/worktree-proximity/tests/route-unit/routes.test.ts +++ b/examples/worktree-proximity/tests/route-unit/routes.test.ts @@ -12,6 +12,7 @@ import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; import { expectDocument, renderRoute, testManifest } from 'agent-bundle/test'; import BeforeTool from '../../src/events/tool/before.js'; +import agentTopologyProvider from '../../src/providers/agent-topology.js'; import { topologyStateDefinition, type TopologyEvents, @@ -40,6 +41,15 @@ const provider = (root: string) => ({ state: 'available' as const, }); +// The generated `.agent-bundle/routes.d.ts` (in this project's tsconfig program) +// makes every declared provider key required on an explicit map, so the fixture +// carries `agentTopology` too; its factory is pure and reports the same honest +// unavailable value the harness would mount. +const providers = (root: string) => ({ + agentTopology: agentTopologyProvider(), + gitWorktree: provider(root), +}); + const eventInput = ( event: 'agent/start' | 'session/start' | 'stop' | 'tool/after' | 'tool/before', native: Record, @@ -80,7 +90,7 @@ const renderEventInput = async ( }, ...(lineage === undefined ? {} : { lineage }), noticeLedger: bindings.noticeLedger, - providers: { gitWorktree: provider(worktreeRoot) }, + providers: providers(worktreeRoot), session: available({ sessionId: 'root-session' }, 'native'), state: bindings.state, workspace: available({ root: worktreeRoot }, 'native'), @@ -438,7 +448,7 @@ describe('worktree proximity journeys', () => { rendered = await renderRoute('tool:coordinator/status', { context: { noticeLedger: bindings.noticeLedger, - providers: { gitWorktree: provider(worktrees.root) }, + providers: providers(worktrees.root), state: bindings.state, }, input: {}, @@ -465,7 +475,7 @@ describe('worktree proximity journeys', () => { const rendered = await renderRoute({ default: BeforeTool }, { context: { actor: available({ id: 'agent-a' }, 'native'), - providers: { gitWorktree: provider(worktrees.a) }, + providers: providers(worktrees.a), }, input: eventInput( 'tool/before', diff --git a/examples/worktree-proximity/tsconfig.json b/examples/worktree-proximity/tsconfig.json index 67cbb7fcf..4870552b4 100644 --- a/examples/worktree-proximity/tsconfig.json +++ b/examples/worktree-proximity/tsconfig.json @@ -5,6 +5,7 @@ }, "include": [ "agent-bundle.config.ts", + ".agent-bundle/routes.d.ts", "src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 61f8fc9fb..28dac3b21 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -512,7 +512,9 @@ kind, and the module provenance. The same generated `.agent-bundle/routes.d.ts` registers the route contracts on `@agent-bundle/runtime`'s `Register` interface. With that file in the project's -TypeScript program (add it to `tsconfig.json` `include`), a string-literal route +TypeScript program (`create-agent-bundle` templates list it in `tsconfig.json` +`include` by default; `agent-bundle validate` warns with `AB4834` when a routed +project's tsconfig leaves it out), a string-literal route id is checked against the compiled ids, `input` is typed from the route's `inputSchema`, and `result` from its `resultSchema` (an event route's `input` is its `{ canonical, native }` payload and its `result` `undefined`); diff --git a/packages/agent-bundle/src/build/framework-plugins.ts b/packages/agent-bundle/src/build/framework-plugins.ts new file mode 100644 index 000000000..bd5e52f6e --- /dev/null +++ b/packages/agent-bundle/src/build/framework-plugins.ts @@ -0,0 +1,45 @@ +/** + * The Rsbuild plugins agent-bundle registers itself in the configs it + * synthesizes, keyed by plugin `name`. Rsbuild's plugin manager appends every + * plugin it is handed and never dedupes by name, and both engines the layers + * from `composeToolsLayers` are handed to (`mergeRslibConfig` for artifact + * scripts, MCP entries, hooks, and the package build; `mergeRsbuildConfig` for + * MCP App views) concatenate `plugins` arrays, so a consumer who adds one of + * these through `tools.rsbuild.plugins` registers it twice. `validateTools` + * reports that as AB4724 instead. + * + * The names are literals rather than instances so the validator does not + * load a bundler plugin to read a string; `framework-plugins.test.ts` pins + * each literal to the plugin it names. + */ +export const frameworkOwnedRsbuildPlugins: ReadonlyMap = new Map([ + // `pluginReact()` from rslib.ts (every synthesized entry) and mcp-apps.ts + // (every React-syntax view): automatic JSX runtime, fast refresh off. + ['rsbuild:react', '@rsbuild/plugin-react'], +]); + +const hasPluginName = (value: unknown): value is { readonly name: string } => + typeof value === 'object' && value !== null && typeof (value as { readonly name?: unknown }).name === 'string'; + +/** + * The plugin names a `tools.rsbuild.plugins` value supplies that collide with + * a framework-owned registration, in authored order and deduplicated. Only + * statically visible plugin objects are inspected: nested arrays are + * flattened the way Rsbuild flattens them, while `false`/`null`/`undefined` + * holes and plugins supplied as Promises (which Rsbuild also accepts) carry + * no name to compare until the build awaits them. + */ +export const frameworkOwnedPluginCollisions = (plugins: unknown): readonly string[] => { + const collisions: string[] = []; + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (hasPluginName(value) && frameworkOwnedRsbuildPlugins.has(value.name) && !collisions.includes(value.name)) { + collisions.push(value.name); + } + }; + visit(plugins); + return collisions; +}; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 0c90748cb..6cff4035a 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -3,6 +3,7 @@ import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } fr import { capabilityIsSupported, cliBinCapability } from '../adapters/capability-state.ts'; import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts'; +import { frameworkOwnedPluginCollisions, frameworkOwnedRsbuildPlugins } from '../build/framework-plugins.ts'; import type { CapabilityState } from '../core/capabilities.ts'; import { toPosixRelative } from '../core/paths.ts'; import { isPlainRecord, isRecord } from '../core/strict-json.ts'; @@ -1850,6 +1851,20 @@ const validateTools = (loaded: LoadedConfig): Diagnostic[] => { const rsbuild = (tools as Record).rsbuild; if (rsbuild !== undefined && !isRecord(rsbuild)) { diagnostics.push(sourceDiagnostic('AB4722', 'Tools rsbuild must be an Rsbuild environment-config object.', loaded.configPath)); + } else if (rsbuild !== undefined) { + // AB4724: the hatch merges *beside* the framework profile, and Rsbuild + // never dedupes plugins by name, so a framework-owned plugin supplied + // here would register twice. A config problem with one deterministic fix, + // so it is an error at validation time like the rest of the AB472x family. + for (const name of frameworkOwnedPluginCollisions(rsbuild.plugins)) { + const specifier = frameworkOwnedRsbuildPlugins.get(name) ?? name; + diagnostics.push(sourceDiagnostic( + 'AB4724', + `Tools rsbuild plugin ${JSON.stringify(name)} (${specifier}) is already registered by agent-bundle; Rsbuild does not dedupe plugins by name, so the build would run it twice.`, + loaded.configPath, + `Remove ${specifier} from tools.rsbuild.plugins; agent-bundle registers it in every config it synthesizes.`, + )); + } } const rspack = (tools as Record).rspack; if ( diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 0c3d3bcd0..542e70a73 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -37,6 +37,7 @@ import type { } from '../core/types.ts'; import { emptyCompiledRouteGraph } from '../routes/graph.ts'; import { writeRouteTypes } from '../routes/typegen.ts'; +import { routeTypesProgramDiagnostics } from '../routes/typegen-program.ts'; import type { CompiledRouteGraph } from '../routes/types.ts'; import type { DevRuntimePreparedMcpApp, DevRuntimePreparedMcpServer, DevRuntimePreparedProject } from './runtime-provider.ts'; import { freezeJsonValue, type JsonObject, type JsonValue, type SourceStatus } from './types.ts'; @@ -920,9 +921,12 @@ export class ProjectService { // `validate`, where an operator asks for exactly this judgment. // Development flows keep running without them — a payload that has not // been built yet is a normal dev state — and builds are separately - // guarded by their own hard refusals. + // guarded by their own hard refusals. The AB4834 program check judges + // the declaration `writeRouteTypes` just published, so it runs here, + // after the write, and only for `validate` like every other nudge. diagnostics = [ ...(command === 'validate' ? sourceDiagnostics : []), + ...(command === 'validate' ? routeTypesProgramDiagnostics(root) : []), ...validateModel(model, registry), ]; for (const target of model.targets) { diff --git a/packages/agent-bundle/src/routes/typegen-program.ts b/packages/agent-bundle/src/routes/typegen-program.ts new file mode 100644 index 000000000..c6ddc8730 --- /dev/null +++ b/packages/agent-bundle/src/routes/typegen-program.ts @@ -0,0 +1,79 @@ +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +// Aliased: the workspace toolchain is typescript@7 (native compiler, no +// single-file parse API), and a plain `typescript` dependency here would +// shadow it for rslib's declaration generation. The alias ships the 5.x +// compiler API for config parsing only. +import ts from 'typescript-5'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { routeTypesRelativePath } from './typegen.ts'; + +/** The file `tsc -p` and every editor read as the project's TypeScript program. */ +const projectTsconfigFilename = 'tsconfig.json'; + +const comparablePath = (path: string): string => { + const resolved = resolve(path); + return ts.sys.useCaseSensitiveFileNames ? resolved : resolved.toLowerCase(); +}; + +/** + * The root file names of one tsconfig's program, resolved the way `tsc -p` + * resolves them (`extends`, `files`, `include`, `exclude`, against the real + * file system), plus the referenced projects a solution-style root delegates + * to. `undefined` when the file cannot be read or parsed as a config: `tsc` + * reports that failure itself, and a broken tsconfig has no program to be + * missing from. + */ +const programRootFiles = ( + tsconfigPath: string, +): { readonly fileNames: readonly string[]; readonly references: readonly string[] } | undefined => { + const read = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (read.error !== undefined || read.config === undefined) return undefined; + const parsed = ts.parseJsonConfigFileContent( + read.config, + ts.sys, + resolve(tsconfigPath, '..'), + undefined, + tsconfigPath, + ); + return { + fileNames: parsed.fileNames, + references: (parsed.projectReferences ?? []).map((reference) => ts.resolveProjectReferencePath(reference)), + }; +}; + +/** + * AB4834: the generated `.agent-bundle/routes.d.ts` registers the project's + * route contracts and provider keys on `@agent-bundle/runtime`, but only a + * TypeScript program that compiles the file observes them — a program that + * leaves it out type-checks `renderRoute` ids as `string` and `input` / + * `result` as `unknown`, silently. Reported once the declaration has been + * published (a route-free, provider-free project has no file to include) for a + * project whose root `tsconfig.json` program — its own root files or, for a + * solution-style root, one of its referenced projects — does not compile it. + * A project without a root `tsconfig.json` has no program to check. + */ +export const routeTypesProgramDiagnostics = (projectRoot: string): readonly Diagnostic[] => { + const routeTypesPath = join(projectRoot, routeTypesRelativePath); + const tsconfigPath = join(projectRoot, projectTsconfigFilename); + if (!existsSync(routeTypesPath) || !existsSync(tsconfigPath)) return []; + const root = programRootFiles(tsconfigPath); + if (root === undefined) return []; + const expected = comparablePath(routeTypesPath); + const compiles = (fileNames: readonly string[]): boolean => + fileNames.some((fileName) => comparablePath(fileName) === expected); + if (compiles(root.fileNames)) return []; + for (const reference of root.references) { + const referenced = existsSync(reference) ? programRootFiles(reference) : undefined; + if (referenced !== undefined && compiles(referenced.fileNames)) return []; + } + return [{ + code: 'AB4834', + message: `${projectTsconfigFilename} does not include the generated ${routeTypesRelativePath}, so renderRoute and renderRouteEvents type-check route ids as string and input/result as unknown.`, + recovery: `Add ${JSON.stringify(routeTypesRelativePath)} to the "include" array of ${projectTsconfigFilename}; agent-bundle build, dev, and validate keep the file current, and it stays gitignored.`, + severity: 'warning', + sourcePath: tsconfigPath, + }]; +}; diff --git a/packages/agent-bundle/tests/framework-plugins.test.ts b/packages/agent-bundle/tests/framework-plugins.test.ts new file mode 100644 index 000000000..653d69db1 --- /dev/null +++ b/packages/agent-bundle/tests/framework-plugins.test.ts @@ -0,0 +1,28 @@ +import { pluginReact } from '@rsbuild/plugin-react'; +import { expect, it } from '@rstest/core'; + +import { frameworkOwnedPluginCollisions, frameworkOwnedRsbuildPlugins } from '../src/build/framework-plugins.ts'; + +// The registry pins plugin names as literals so the validator never loads a +// bundler plugin to read a string; this is the drift check that keeps each +// literal equal to the name the plugin actually publishes. +it('names every framework-owned plugin by the name its package publishes', () => { + expect(frameworkOwnedRsbuildPlugins.get(pluginReact().name)).toBe('@rsbuild/plugin-react'); + expect([...frameworkOwnedRsbuildPlugins.keys()]).toEqual(['rsbuild:react']); +}); + +it('collects colliding plugin names once, through nested arrays and holes', () => { + expect(frameworkOwnedPluginCollisions([ + pluginReact(), + false, + [null, undefined, pluginReact({ fastRefresh: true }), { name: 'consumer:banner', setup: () => undefined }], + ])).toEqual(['rsbuild:react']); +}); + +it('reports nothing for absent, non-array, unrelated, or deferred plugins', () => { + expect(frameworkOwnedPluginCollisions(undefined)).toEqual([]); + expect(frameworkOwnedPluginCollisions('rsbuild:react')).toEqual([]); + expect(frameworkOwnedPluginCollisions([{ name: 'consumer:banner', setup: () => undefined }])).toEqual([]); + // A Promise carries no name until awaited; the validator inspects only what is statically visible. + expect(frameworkOwnedPluginCollisions([Promise.resolve(pluginReact())])).toEqual([]); +}); diff --git a/packages/agent-bundle/tests/package-conventions.test.ts b/packages/agent-bundle/tests/package-conventions.test.ts index 58e188666..8a878caf6 100644 --- a/packages/agent-bundle/tests/package-conventions.test.ts +++ b/packages/agent-bundle/tests/package-conventions.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pluginReact } from '@rsbuild/plugin-react'; import { afterEach, describe, expect, it } from '@rstest/core'; import { @@ -263,6 +264,38 @@ describe('bin, lib, and tools validation', () => { }); expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); }); + + // AB4724: Rsbuild appends every plugin it is handed without deduping by + // name, and the hatch merges beside the framework profile, so a consumer + // re-adding a framework-owned plugin would register it twice. + it('rejects a framework-owned plugin re-added through tools.rsbuild.plugins with AB4724', async () => { + const diagnostics = await validated({ + tools: { rsbuild: { plugins: [pluginReact(), [false, pluginReact({ fastRefresh: true })]] } }, + }); + const collisions = diagnostics.filter((diagnostic) => diagnostic.code === 'AB4724'); + expect(collisions).toHaveLength(1); + expect(collisions[0]).toMatchObject({ + message: expect.stringContaining('"rsbuild:react" (@rsbuild/plugin-react) is already registered by agent-bundle'), + recovery: expect.stringContaining('Remove @rsbuild/plugin-react from tools.rsbuild.plugins'), + severity: 'error', + }); + expect(collisions[0]!.sourcePath).toMatch(/agent-bundle\.config\.ts$/u); + }); + + it('accepts unrelated plugins in tools.rsbuild.plugins without a collision diagnostic', async () => { + const diagnostics = await validated({ + tools: { + rsbuild: { + plugins: [ + { name: 'consumer:banner', setup: () => undefined }, + [null, { name: 'consumer:nested', setup: () => undefined }], + Promise.resolve(pluginReact()), + ], + }, + }, + }); + expect(diagnostics).toEqual([]); + }); }); describe('artifact output validation', () => { diff --git a/packages/agent-bundle/tests/route-types-program.test.ts b/packages/agent-bundle/tests/route-types-program.test.ts new file mode 100644 index 000000000..6b712a766 --- /dev/null +++ b/packages/agent-bundle/tests/route-types-program.test.ts @@ -0,0 +1,143 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { inspect, validate } from '../src/api.ts'; +import { routeTypesProgramDiagnostics } from '../src/routes/typegen-program.ts'; +import { routeTypesRelativePath } from '../src/routes/typegen.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const routeModule = [ + "import { z } from 'zod';", + 'export const inputSchema = z.object({ service: z.string() });', + 'export const resultSchema = z.object({ status: z.string() });', + 'export default async () => undefined;', + '', +].join('\n'); + +const tsconfig = (include: readonly string[], extra: Readonly> = {}): string => + `${JSON.stringify({ compilerOptions: { module: 'NodeNext', strict: true }, include, ...extra }, null, 2)}\n`; + +const createProject = async (files: Readonly>): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-route-types-program-'))); + roots.push(root); + for (const [path, contents] of Object.entries({ + 'agent-bundle.config.ts': [ + 'export default {', + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'package.json': '{"type":"module"}\n', + ...files, + })) { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents); + } + return root; +}; + +const codesOf = (diagnostics: readonly { readonly code: string }[]): string[] => + diagnostics.map((diagnostic) => diagnostic.code); + +describe('AB4834 generated route declarations outside the TypeScript program', () => { + it('warns from validate when tsconfig.json leaves the published declaration out of the program', async () => { + const root = await createProject({ + 'src/mcp/status/tools/report.ts': routeModule, + 'tsconfig.json': tsconfig(['agent-bundle.config.ts', 'src/**/*.ts', 'tests/**/*.ts']), + }); + + const result = await validate({ root }); + // `validate` published the declaration first; the warning is about that file. + expect(existsSync(join(root, routeTypesRelativePath))).toBe(true); + const warnings = result.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4834'); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatchObject({ + message: expect.stringContaining('tsconfig.json does not include the generated .agent-bundle/routes.d.ts'), + recovery: expect.stringContaining('Add ".agent-bundle/routes.d.ts" to the "include" array of tsconfig.json'), + severity: 'warning', + sourcePath: join(root, 'tsconfig.json'), + }); + expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + }); + + it('is silent when the declaration is included explicitly, by glob, or through files', async () => { + for (const config of [ + tsconfig(['agent-bundle.config.ts', '.agent-bundle/routes.d.ts', 'src/**/*.ts']), + // A glob that names the dot-directory reaches it; a bare `**/*` never does. + tsconfig(['src/**/*.ts', '.agent-bundle/**/*']), + tsconfig(['src/**/*.ts'], { files: ['.agent-bundle/routes.d.ts'] }), + ]) { + const root = await createProject({ + 'src/mcp/status/tools/report.ts': routeModule, + 'tsconfig.json': config, + }); + const result = await validate({ root }); + expect(codesOf(result.diagnostics), config).not.toContain('AB4834'); + } + }); + + it('follows extends and project references before judging the program', async () => { + const extending = await createProject({ + 'src/mcp/status/tools/report.ts': routeModule, + 'tsconfig.base.json': tsconfig(['agent-bundle.config.ts', '.agent-bundle/routes.d.ts', 'src/**/*.ts']), + 'tsconfig.json': '{ "extends": "./tsconfig.base.json" }\n', + }); + expect(codesOf((await validate({ root: extending })).diagnostics)).not.toContain('AB4834'); + + const solution = await createProject({ + 'src/mcp/status/tools/report.ts': routeModule, + 'tsconfig.json': `${JSON.stringify({ files: [], references: [{ path: './tsconfig.src.json' }] }, null, 2)}\n`, + 'tsconfig.src.json': tsconfig(['.agent-bundle/routes.d.ts', 'src/**/*.ts'], { compilerOptions: { composite: true, module: 'NodeNext' } }), + }); + expect(codesOf((await validate({ root: solution })).diagnostics)).not.toContain('AB4834'); + + const wildcardOnly = await createProject({ + 'src/mcp/status/tools/report.ts': routeModule, + 'tsconfig.json': `${JSON.stringify({ files: [], references: [{ path: './tsconfig.src.json' }] }, null, 2)}\n`, + // `**/*` never descends into dot-directories, so this program still omits the declaration. + 'tsconfig.src.json': tsconfig(['**/*'], { compilerOptions: { composite: true, module: 'NodeNext' } }), + }); + expect(codesOf((await validate({ root: wildcardOnly })).diagnostics)).toContain('AB4834'); + }); + + it('has nothing to report without a tsconfig, without routes, or with an unparsable tsconfig', async () => { + const noTsconfig = await createProject({ 'src/mcp/status/tools/report.ts': routeModule }); + expect(codesOf((await validate({ root: noTsconfig })).diagnostics)).not.toContain('AB4834'); + + const routeFree = await createProject({ + 'src/skills/notes/SKILL.md': '---\nname: notes\ndescription: Notes.\n---\n\n# Notes\n\nBody.\n', + 'tsconfig.json': tsconfig(['agent-bundle.config.ts']), + }); + expect(codesOf((await validate({ root: routeFree })).diagnostics)).not.toContain('AB4834'); + expect(existsSync(join(routeFree, routeTypesRelativePath))).toBe(false); + + const broken = await createProject({ + 'src/mcp/status/tools/report.ts': routeModule, + 'tsconfig.json': '{ "include": [\n', + }); + expect(codesOf((await validate({ root: broken })).diagnostics)).not.toContain('AB4834'); + expect(routeTypesProgramDiagnostics(broken)).toEqual([]); + }); + + it('is a validate-only judgment: inspect and build flows do not surface it', async () => { + const root = await createProject({ + 'src/mcp/status/tools/report.ts': routeModule, + 'tsconfig.json': tsconfig(['src/**/*.ts']), + }); + const inspected = await inspect({ root }); + expect(codesOf(inspected.diagnostics)).not.toContain('AB4834'); + // The declaration is on disk after inspect too, so the check itself would fire. + expect(codesOf(routeTypesProgramDiagnostics(root))).toEqual(['AB4834']); + }); +}); diff --git a/packages/create-agent-bundle/templates/cli-tool/tsconfig.json b/packages/create-agent-bundle/templates/cli-tool/tsconfig.json index 6c032361a..f4e4fcb4e 100644 --- a/packages/create-agent-bundle/templates/cli-tool/tsconfig.json +++ b/packages/create-agent-bundle/templates/cli-tool/tsconfig.json @@ -15,6 +15,7 @@ }, "include": [ "agent-bundle.config.ts", + ".agent-bundle/routes.d.ts", "rstest.projection.config.ts", "src/**/*.ts", "tests/**/*.ts" diff --git a/packages/create-agent-bundle/templates/mcp-server/tsconfig.json b/packages/create-agent-bundle/templates/mcp-server/tsconfig.json index 2aa115e84..d67e71a37 100644 --- a/packages/create-agent-bundle/templates/mcp-server/tsconfig.json +++ b/packages/create-agent-bundle/templates/mcp-server/tsconfig.json @@ -16,6 +16,7 @@ }, "include": [ "agent-bundle.config.ts", + ".agent-bundle/routes.d.ts", "rstest.projection.config.ts", "rstest.route-unit.config.ts", "src/**/*.ts", diff --git a/packages/create-agent-bundle/templates/minimal/tsconfig.json b/packages/create-agent-bundle/templates/minimal/tsconfig.json index fb5b826a4..5f6d75239 100644 --- a/packages/create-agent-bundle/templates/minimal/tsconfig.json +++ b/packages/create-agent-bundle/templates/minimal/tsconfig.json @@ -15,6 +15,7 @@ }, "include": [ "agent-bundle.config.ts", + ".agent-bundle/routes.d.ts", "tests/**/*.ts" ] } diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts index 2c5c0f417..211c178b7 100644 --- a/packages/create-agent-bundle/tests/scaffold.test.ts +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -319,6 +319,20 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys expect(config).not.toContain("'codex'"); })); + // The generated `.agent-bundle/routes.d.ts` types `renderRoute` only when + // the project's TypeScript program compiles it, so every template's + // tsconfig includes the (gitignored) file by default rather than leaving + // route ids as `string` until the user finds the note in the docs. + for (const template of ['minimal', 'mcp-server', 'cli-tool'] as const) { + it.effect(`includes the generated route declarations in the ${template} tsconfig program`, () => Effect.gen(function* () { + const path = yield* Path.Path; + const { root } = yield* scaffoldTemplate(template); + const tsconfig = yield* readJson<{ readonly include: readonly string[] }>(path.join(root, 'tsconfig.json')); + expect(tsconfig.include).toContain('.agent-bundle/routes.d.ts'); + expect(yield* readText(path.join(root, '.gitignore'))).toContain('.agent-bundle/'); + })); + } + it.effect('validates local runtime tarballs before writing scaffold files', () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 602ffe3d5..116105263 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -224,6 +224,12 @@ the build, at config inspection for statically visible `externals` and through a for function-form ones. The hatch customizes *how code compiles*, never *what the artifact promises*. +The hatch merges *beside* the framework profile: `plugins` arrays concatenate, and Rsbuild never +dedupes plugins by name. Re-adding a plugin the framework already registers — `@rsbuild/plugin-react` +(`rsbuild:react`), which every synthesized entry and every React-syntax MCP App view carries — +through `tools.rsbuild.plugins` would run it twice, so `agent-bundle validate` reports it as +`AB4724` naming the plugin and its package. Remove the entry; the framework registers it for you. + One dual-engine caveat: artifact scripts, MCP entries, hook wrappers, and the package build compile through Rslib and run under the bundler versions nested inside `@rslib/core`, while MCP App views compile through the workspace-pinned `@rsbuild/core`. A class imported from a diff --git a/website/docs/en/guide/development/index.mdx b/website/docs/en/guide/development/index.mdx index 58215588b..dd196202b 100644 --- a/website/docs/en/guide/development/index.mdx +++ b/website/docs/en/guide/development/index.mdx @@ -38,7 +38,9 @@ as one `AB7103` **warning** on the succeeded build attempt and retries on the ne Development also publishes generated route declarations at `.agent-bundle/routes.d.ts` from the same compiled graph. Each write goes to a sibling temporary file and is renamed over the prior complete declaration atomically, so invalid source keeps the last-good file, and a successful -route-free preparation removes it. +route-free preparation removes it. The declaration only types `renderRoute` when the project's +TypeScript program compiles it: keep `".agent-bundle/routes.d.ts"` in `tsconfig.json` `include` +(the templates ship it that way; `agent-bundle validate` warns with `AB4834` when it is missing). ## The three surfaces diff --git a/website/docs/en/guide/development/testing.mdx b/website/docs/en/guide/development/testing.mdx index bab53862e..6eaa8c97c 100644 --- a/website/docs/en/guide/development/testing.mdx +++ b/website/docs/en/guide/development/testing.mdx @@ -60,8 +60,10 @@ of its own. The compiler's generated `.agent-bundle/routes.d.ts` registers the project's route contracts on `@agent-bundle/runtime`'s `Register` interface, the way it already declares provider keys. Once -that file is part of the project's TypeScript program (add `".agent-bundle/routes.d.ts"` to -`tsconfig.json` `include`), a `renderRoute` call written with a string literal is checked against +that file is part of the project's TypeScript program — `create-agent-bundle` templates list +`".agent-bundle/routes.d.ts"` in `tsconfig.json` `include` by default, the file itself stays +gitignored, and `agent-bundle validate` warns with `AB4834` when a project that compiles routes or +providers leaves it out — a `renderRoute` call written with a string literal is checked against the compiled route ids — the editor completes them, and a typo is rejected naming the registered alternatives — while `input` and `result` come from that route's own `inputSchema` and `resultSchema`: diff --git a/website/docs/en/reference/configuration.mdx b/website/docs/en/reference/configuration.mdx index 0f5387451..c7f8005df 100644 --- a/website/docs/en/reference/configuration.mdx +++ b/website/docs/en/reference/configuration.mdx @@ -180,7 +180,10 @@ Development-only settings that never become part of a built artifact. The single bundler escape hatch. Both fragments merge last-but-bounded into every bundler config the framework synthesizes, and the artifact invariant assertions still run after the merge, so a hatch value that breaks an artifact contract is a hard diagnostic rather than a silent override -(`AB472x`). +(`AB472x`). `AB4720`–`AB4723` check the shape of `tools`, `tools.rsbuild`, and `tools.rspack`; +`AB4724` rejects a `tools.rsbuild.plugins` entry that re-adds a framework-owned plugin +(`@rsbuild/plugin-react`, `rsbuild:react`), because `plugins` arrays concatenate and Rsbuild +never dedupes plugins by name — the framework registers it for you. The hatch executes under two bundler engine copies: artifact scripts, MCP entries, hooks, and the package build compile through Rslib's nested Rsbuild/Rspack, while MCP App views compile through diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index d73d80621..cc1febd0c 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -198,6 +198,11 @@ npx agent-bundle prepack --root . --output artifact --json 在配置检查阶段失败,对函数形式的则通过构建后扫描失败。逃生舱定制的是*代码如何编译*,绝不是*产物承诺 了什么*。 +逃生舱是*并列*合并到框架配置旁边的:`plugins` 数组会拼接,而 Rsbuild 从不按名称去重插件。通过 +`tools.rsbuild.plugins` 再次添加框架已经注册的插件——`@rsbuild/plugin-react`(`rsbuild:react`), +每个合成入口与每个使用 React 语法的 MCP App 视图都带着它——会让它运行两次,因此 `agent-bundle validate` +会以 `AB4724` 报告,并点名该插件及其包名。删除这一项即可;框架会替你注册它。 + 有一个双引擎注意事项:产物脚本、MCP 入口、钩子包装层与包构建通过 Rslib 编译,运行在 `@rslib/core` 内嵌的打包器版本之下;而 MCP App 视图通过工作区固定的 `@rsbuild/core` 编译。因此,从单独安装的 `@rspack/core` 导入的类,与实际执行配置的那个引擎的类身份并不相同。切勿针对导入的 `@rspack/core` diff --git a/website/docs/zh/guide/development/index.mdx b/website/docs/zh/guide/development/index.mdx index 3d06c16da..fbf1c33aa 100644 --- a/website/docs/zh/guide/development/index.mdx +++ b/website/docs/zh/guide/development/index.mdx @@ -32,7 +32,9 @@ npx agent-bundle dev --root . 开发期还会从同一份编译后的路由图,把生成的路由声明发布到 `.agent-bundle/routes.d.ts`。每次写入都先 写入一个同级临时文件,再原子地重命名覆盖先前那份完整声明,因此无效源码会保留上一份可用文件,而一次 -成功的、不含路由的准备过程会移除它。 +成功的、不含路由的准备过程会移除它。只有当项目的 TypeScript 程序编译了这份声明,它才会为 `renderRoute` +提供类型:请把 `".agent-bundle/routes.d.ts"` 保留在 `tsconfig.json` 的 `include` 中(模板默认如此; +缺失时 `agent-bundle validate` 会以 `AB4834` 警告)。 ## 三个表面 diff --git a/website/docs/zh/guide/development/testing.mdx b/website/docs/zh/guide/development/testing.mdx index 59d08eaa8..f18096d0a 100644 --- a/website/docs/zh/guide/development/testing.mdx +++ b/website/docs/zh/guide/development/testing.mdx @@ -53,7 +53,9 @@ export const summarizes = async (): Promise => { 编译器生成的 `.agent-bundle/routes.d.ts` 会把项目的路由契约注册到 `@agent-bundle/runtime` 的 `Register` 接口上——与它已经声明 provider 键的方式相同。一旦该文件成为项目 TypeScript 程序的一部分 -(把 `".agent-bundle/routes.d.ts"` 加入 `tsconfig.json` 的 `include`),用字符串字面量写出的 +(`create-agent-bundle` 模板默认就把 `".agent-bundle/routes.d.ts"` 列在 `tsconfig.json` 的 `include` +中,文件本身仍被 gitignore;当一个编译了路由或 provider 的项目漏掉它时,`agent-bundle validate` 会以 +`AB4834` 警告),用字符串字面量写出的 `renderRoute` 调用就会针对编译后的 route id 做检查——编辑器会补全它们,写错时会被拒绝并列出已注册的 备选项——而 `input` 与 `result` 来自该路由自己的 `inputSchema` 与 `resultSchema`: diff --git a/website/docs/zh/reference/configuration.mdx b/website/docs/zh/reference/configuration.mdx index a0a09fd4f..75cb2aace 100644 --- a/website/docs/zh/reference/configuration.mdx +++ b/website/docs/zh/reference/configuration.mdx @@ -165,6 +165,9 @@ export default defineConfig({ 唯一的打包器逃生舱。两个片段都会以「最后但有界」的方式合并进框架合成的每一份打包器配置,并且产物不变式 断言仍会在合并之后运行,因此破坏产物契约的逃生舱取值是一条硬性诊断,而不是一次无声的覆盖(`AB472x`)。 +`AB4720`–`AB4723` 检查 `tools`、`tools.rsbuild` 与 `tools.rspack` 的形状;`AB4724` 会拒绝在 +`tools.rsbuild.plugins` 中再次添加框架自有插件(`@rsbuild/plugin-react`,`rsbuild:react`)的条目, +因为 `plugins` 数组会拼接、而 Rsbuild 从不按名称去重插件——框架会替你注册它。 逃生舱在两份打包器引擎副本下执行:产物脚本、MCP 入口、钩子与包构建通过 Rslib 内嵌的 Rsbuild/Rspack 编译, 而 MCP App 视图通过工作区固定的 `@rsbuild/core` 编译。绝不要基于导入的 `@rspack/core` 构造插件或执行