diff --git a/.changeset/rfc50-phase2-framework.md b/.changeset/rfc50-phase2-framework.md new file mode 100644 index 000000000..48e30c35d --- /dev/null +++ b/.changeset/rfc50-phase2-framework.md @@ -0,0 +1,20 @@ +--- +"agent-bundle": minor +--- + +RFC #50 Phase 2, framework side. `validate`/`inspect`/`build`/`dev` now +report informational migration nudges (never errors — migrations stay +optional): `AB4730` for a self-connecting stdio MCP entry that a +default-exported factory would upgrade to the framework lifecycle shell, and +`AB4731`/`AB4732`/`AB4733` when `src/cli.ts`, `src/index.ts`, or +`src/mcp/.ts` is present but shadowed by explicit configuration. +`agent-bundle inspect --bundler` dumps the synthesized Rslib/Rsbuild +configuration for every generated output — artifact scripts, MCP entries, +hook wrappers, MCP App views, and the `dist/` package build — post-`tools`- +hatch merge with the invariant hook visible, composed by the same functions +the build lowers so the dump cannot drift. `agent-bundle dev` extends the +debounced, serialized rebuild pass to the framework-owned package build: +`dist/` bin/lib outputs rebuild when their provenance-tracked inputs change, +and a package build failure surfaces as one `AB7103` warning without +invalidating the committed artifact epoch. New `docs/diagnostics.md` +reference documents the diagnostic families and the new codes. diff --git a/README.md b/README.md index 8ebe2c832..0a88dc37f 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ The same config also owns the npm package build — no second bundler config, bi - `build` — validate the project and write an artifact (plus the `bin`/`lib` package build when declared) - `validate` — check project source, or a built artifact with `--artifact ` -- `inspect` — show the normalized configuration and per-target plans -- `dev` — serve the local development workbench +- `inspect` — show the normalized configuration and per-target plans; `--bundler` dumps the synthesized bundler configs (post-`tools`-hatch merge) +- `dev` — serve the local development workbench and rebuild the `dist/` package build when its inputs change - `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 - `eval` — run eval suites against a built artifact diff --git a/docs/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 000000000..36789e357 --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,85 @@ +# Diagnostics reference + +Every agent-bundle failure or nudge is one structured diagnostic: a stable +`code` (`AB` + four digits), a `severity` (`error`, `warning`, or `info`), a +`message`, and usually a `sourcePath` and a `recovery` hint. Commands exit +nonzero only when an **error** diagnostic is present; warnings and infos never +gate a build, a validation, or a dev rebuild. + +## Code families + +| Family | Area | +| --- | --- | +| `AB30xx` | Skill Markdown parsing (missing or malformed frontmatter). | +| `AB40xx` | Plugin metadata and Skill source validation. | +| `AB41xx` | Normalized model invariants (unknown targets, duplicate IDs and outputs). | +| `AB42xx` | Hook configuration and native hook sources. | +| `AB43xx` | MCP server and MCP App configuration. | +| `AB44xx` | Script configuration. | +| `AB4500` | Registered config extensions (strict finite JSON). | +| `AB46xx` | Assets and the generated-runtime floor. | +| `AB470x` | Package build `bin` configuration (`AB4706`: artifact output overlaps `dist`). | +| `AB471x` | Package build `lib` configuration. | +| `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. | +| `AB473x` | Migration nudges (informational; see below). | +| `AB5000` | General CLI and adapter failures. | +| `AB7xxx` | Project preparation and development rebuilds. | +| `AB8xxx` | Development server configuration. | +| `AB9xxx` | Eval selection, harnesses, and persisted runs. | + +## Migration nudges (`AB4730`–`AB4733`) + +The entry conventions and the framework-owned stdio lifecycle shell (RFC #50) +replaced patterns consumers previously wrote by hand. When `validate`, +`inspect`, `build`, or `dev` prepares project source and finds one of those +pre-convention patterns, it reports an **informational** nudge. Nudges are +never errors and never block anything — migrations stay optional, per the +RFC's additive-first principle. The CLI prints them in human `validate` +output and includes them in every `--json` diagnostics array. + +### `AB4730` — self-connecting stdio MCP entry + +A local MCP server entry module (explicit `entry:` or the conventional +`src/mcp/.ts`) has no default export, so the build bundles it +byte-for-byte instead of wrapping it in the framework stdio lifecycle shell +(console-to-stderr guard, SIGINT/SIGTERM, stdin-EOF exit, bounded shutdown, +heartbeat). The detection is the same static default-export scan the build +uses, so the nudge and the build always agree. + +Adopt: default-export a server factory from the entry module. Silence: keep +the self-connecting entry — its behavior is preserved exactly. + +### `AB4731` — `src/cli.ts` shadowed by explicit `bin` config + +`src/cli.ts` (or `.tsx`) exists, but the explicit `bin` configuration never +references it, so the conventional package bin is silently shadowed. +`bin: false` is a deliberate opt-out and stays silent. + +Adopt: remove the explicit `bin` configuration, or point one entry at the +file. Silence: remove the file, or keep the explicit config knowingly. + +### `AB4732` — `src/index.ts` shadowed by explicit `lib` config + +`src/index.ts` (or `.tsx`) exists, but the explicit `lib` configuration +points elsewhere. `lib: false` is a deliberate opt-out and stays silent. + +Adopt: remove the explicit `lib` configuration, or point it at the file. +Silence: remove the file, or keep the explicit config knowingly. + +### `AB4733` — `src/mcp/.ts` shadowed by explicit server config + +The conventional stdio entry file exists for a declared server, but that +server names an explicit `entry`, `command`, or `url` that does not resolve +to it — a confusable state where the file on disk is not what runs. + +Adopt: drop the explicit `entry`/`command`/`url` so the convention applies. +Silence: remove the shadowed file. + +## Development package build (`AB7103`) + +`agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin +and lib outputs) inside the same serialized rebuild pass that publishes +artifact epochs. A package build failure never invalidates the artifact epoch +that already committed; it surfaces as one `AB7103` **warning** on the +succeeded build attempt, and the package build retries on the next +invalidation. See `docs/entry-conventions.md` for the dev-watch contract. diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 7daa2e597..5d5519250 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -18,10 +18,11 @@ node-consumable package build under `dist/` — the outputs `package.json` | `bin: { '': './src/cli.ts' }` | `dist/bin/.js` | Self-executing ESM bundle, `#!/usr/bin/env node` shebang, executable bit. | | `lib: { entry: './src/index.ts', dts: true }` | `dist/.js` + `dist/**/*.d.ts` | Single-entry ESM profile, node target, es2022 syntax. | -- The package build runs only for `agent-bundle build` (CLI, or - `build({ packageOutputs: true })` through the API). Programmatic artifact - operations — temporary artifacts, the dev workbench, evals — never write - `dist/`. +- The package build runs for `agent-bundle build` (CLI, or + `build({ packageOutputs: true })` through the API) and inside the + `agent-bundle dev` rebuild loop (see “Dev-watch of the package build” + below). Other programmatic artifact operations — temporary artifacts, + evals — never write `dist/`. - Outputs are staged and published atomically, and their provenance (bytes, SHA-256, sorted project-relative source inputs) is reported on the build result exactly like artifact files. @@ -56,6 +57,16 @@ entries carry `provenance.kind: 'conventional'` in the normalized model. Conventions match `.ts` and `.tsx` files exactly. +### Migration nudges + +Source validation reports **informational** nudges (never errors — migrations +stay optional) when a project exhibits a pre-convention pattern: `AB4730` for +a self-connecting stdio entry that a default-exported factory would upgrade +to the framework lifecycle shell, and `AB4731`/`AB4732`/`AB4733` when +`src/cli.ts`, `src/index.ts`, or `src/mcp/.ts` exists but explicit +configuration shadows it. `bin: false` / `lib: false` opt-outs stay silent. +See `docs/diagnostics.md` for each trigger and how to adopt or silence it. + ## Generated entry shells The framework provides the entry files consumers used to write by hand @@ -133,6 +144,52 @@ self-containment) fails the build with a hard diagnostic instead of silently overriding the contract. The hatch customizes *how code compiles*, never *what the artifact promises*. +### `agent-bundle inspect --bundler` + +```sh +agent-bundle inspect --bundler [--target ] [--json] +``` + +Dumps the synthesized bundler configuration for every output the build +composes — artifact scripts, MCP entries, hook wrappers, the per-target MCP +Apps Rsbuild config, and the `dist/` package build — exactly as the build +lowers it: 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 artifact output 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. + +## Dev-watch of the package build + +`agent-bundle dev` rebuilds the `dist/` bin and lib outputs inside the same +debounced, serialized rebuild pass that publishes artifact epochs, with a +provenance-based incremental boundary: after a successful package build, the +sorted source inputs of every emitted file (recorded from bundler stats) are +kept, and the next rebuild is skipped unless an invalidated path was one of +those inputs, the configuration file, `package.json`, or `tsconfig.json` +changed, the rebuild identity changed — the normalized `bin`/`lib` +declaration plus the `tools` escape hatch, with hatch functions compared by +source text — the invalidation was manual or initial, or the previous +package build failed. When every package entry disappears within a live +session (entries removed or opted out), the outputs that session previously +published are removed; outputs from earlier sessions are untouched, matching +`agent-bundle build`. A package build failure never invalidates the +committed artifact epoch — it surfaces as one `AB7103` warning on the +succeeded attempt and retries on the next invalidation. The boundary this +does **not** cover: a brand-new file that changes module resolution without +touching a tracked input is picked up on the next tracked change, not +instantly. + ## `agent-bundle mcp run` ```sh diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index a5371b0f1..85dd063e5 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -62,11 +62,12 @@ the repository's `docs/entry-conventions.md` for the full contract. | `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect normalized targets and adapter plans from source. | +| `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) 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. | | `agent-bundle hooks list` / `hooks simulate` | List generated hooks, or run one emitted wrapper. | | `agent-bundle eval` | Run deterministic or native Claude/Codex eval suites and record a run. | -| `agent-bundle dev` | Serve the packaged developer workbench on loopback. | +| `agent-bundle dev` | Serve the packaged developer workbench on loopback; rebuilds the `dist/` package build when its inputs change. | `validate --artifact`, `mcp`, and `hooks` work against a built artifact with project sources deleted. diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index be487b509..bfb8ce693 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -33,6 +33,8 @@ export { parseArtifactManifest, serializeArtifactManifest, } from './build/manifest.ts'; +import { composeBundlerInspection, type BundlerInspection } from './build/inspect-bundler.ts'; +export type { BundlerInspection, BundlerInspectionEntry } from './build/inspect-bundler.ts'; import { validateArtifact } from './build/validate-artifact.ts'; import { freezeDiagnostics, hasErrors, DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; export type { Diagnostic, DiagnosticSeverity } from './core/diagnostics.ts'; @@ -197,7 +199,7 @@ export interface InspectionPlan { } export interface InspectOptions extends ProjectOptions { - readonly focus?: 'hooks' | 'skills'; + readonly focus?: 'bundler' | 'hooks' | 'skills'; readonly target?: string; } @@ -207,6 +209,7 @@ export interface ReadyInspectResult { readonly plans: readonly InspectionPlan[]; readonly projectContext: ProjectContext; readonly selected?: { + readonly bundler?: BundlerInspection; readonly hooks?: NormalizedPlugin['hooks']; readonly skills?: NormalizedPlugin['skills']; }; @@ -434,9 +437,29 @@ export const inspect = async (options: InspectOptions): Promise = ), ])); } + let bundler: BundlerInspection | undefined; + if (options.focus === 'bundler') { + try { + bundler = await composeBundlerInspection({ + model, + targets: plans.map((plan) => ({ hookEntries: plan.hookEntries, name: plan.target })), + ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), + }); + } catch { + return invalidInspection(freezeDiagnostics([ + ...prepared.diagnostics, + projectDiagnostic( + 'AB7001', + 'Unable to compose the bundler inspection.', + { sourcePath: prepared.configPath }, + ), + ])); + } + } const selected = options.focus === undefined ? undefined : Object.freeze({ + ...(bundler === undefined ? {} : { bundler }), ...(options.focus === 'hooks' ? { hooks: model.hooks } : {}), ...(options.focus === 'skills' ? { skills: model.skills } : {}), }); diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts new file mode 100644 index 000000000..0e4f183ef --- /dev/null +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -0,0 +1,285 @@ +import type { TargetHookEntry } from '../adapters/types.ts'; +import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; +import { scanEntryExports } from './entry-exports.ts'; +import { + generatedExecutableEntrySource, + generatedStdioMcpEntrySource, + mcpEntryRuntimePath, + mcpEntryRuntimeSpecifier, +} from './entry-shell.ts'; +import { planCompiledMcpEntries } from './entries.ts'; +import { composeMcpAppsRsbuildConfig, planCompiledMcpApps } from './mcp-apps.ts'; +import { planPackageEntries } from './package-build.ts'; +import { composeEntryLibConfig, type RslibEntry } from './rslib.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. + * + * Two build-time-only values are replaced with stable tokens so the output + * is deterministic for one project: the artifact output 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. + */ + +export interface BundlerInspectionEntry { + readonly bundler: 'rsbuild' | 'rslib'; + /** The composed config, JSON-rendered: functions appear as `[function ]`. */ + 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 name: string; + /** POSIX output path relative to the artifact root (targets) or project root (package build). */ + readonly outputPath: string; + /** The authored entry module (absent for the per-target MCP Apps config). */ + readonly source?: string; + readonly target?: string; +} + +export interface BundlerInspection { + readonly entries: readonly BundlerInspectionEntry[]; +} + +export const generatedDtsTsconfigToken = ''; + +const artifactOutputToken = (target: string): string => `/${target}`; + +const isPlainObject = (value: object): boolean => { + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +/** + * 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 ]`. + */ +const renderConfigValue = (value: unknown, ancestors = new Set()): unknown => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + 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); + if (ancestors.has(value)) return '[circular]'; + + ancestors.add(value); + try { + if (Array.isArray(value)) { + return value.map((item) => renderConfigValue(item, ancestors)); + } + if (!isPlainObject(value)) { + return `[object ${value.constructor?.name ?? 'unknown'}]`; + } + return Object.fromEntries(Object.entries(value) + .filter(([, item]) => item !== undefined) + .map(([key, item]) => [key, renderConfigValue(item, ancestors)])); + } finally { + ancestors.delete(value); + } +}; + +const rslibInspectionEntry = (options: { + readonly entry: RslibEntry; + readonly kind: BundlerInspectionEntry['kind']; + readonly name: string; + readonly outputPath: string; + readonly outputRoot: string; + readonly source: string; + readonly target?: string; + readonly tools?: AgentBundleToolsConfig; +}): BundlerInspectionEntry => Object.freeze({ + bundler: 'rslib', + config: renderConfigValue(composeEntryLibConfig(options.entry, { + 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, + target: string, + tools: AgentBundleToolsConfig | undefined, +): Promise => { + const outputRoot = artifactOutputToken(target); + const scripts = model.scripts.filter((script) => + script.mode === 'bundle' && script.targets.includes(target)); + return Promise.all(scripts.map(async (script) => { + const exports = await scanEntryExports(script.source); + return rslibInspectionEntry({ + entry: { + name: script.name, + outputRelativePath: `scripts/${script.name}.mjs`, + source: script.source, + sourceInputs: [], + ...(exports.hasMainExport + ? { + virtualSource: generatedExecutableEntrySource({ + entrySource: script.source, + exportName: 'main', + }), + } + : {}), + }, + kind: 'script', + name: script.name, + outputPath: `${target}/scripts/${script.name}.mjs`, + outputRoot, + source: script.source, + target, + ...(tools === undefined ? {} : { tools }), + }); + })); +}; + +const mcpEntryEntries = async ( + model: NormalizedPlugin, + target: string, + tools: AgentBundleToolsConfig | undefined, +): Promise => { + const outputRoot = artifactOutputToken(target); + const planned = planCompiledMcpEntries(model.mcpServers, { outDir: outputRoot, target }); + return Promise.all(planned.map(async (entry) => { + const wrapped = (await scanEntryExports(entry.source)).hasDefaultExport; + const serverName = entry.id.startsWith('mcp:') ? entry.id.slice('mcp:'.length) : entry.name; + return rslibInspectionEntry({ + entry: { + ...(wrapped + ? { + aliases: { [mcpEntryRuntimeSpecifier]: mcpEntryRuntimePath() }, + virtualSource: generatedStdioMcpEntrySource({ entrySource: entry.source, serverName }), + } + : {}), + name: entry.name, + outputRelativePath: `mcp/${entry.name}.mjs`, + source: entry.source, + sourceInputs: [], + virtualModules: [{ + name: 'agent-bundle/mcp-apps', + source: '/* The MCP App registry virtual module is generated from built app HTML at build time. */', + }], + }, + kind: 'mcp-entry', + name: serverName, + outputPath: `${target}/mcp/${entry.name}.mjs`, + outputRoot, + source: entry.source, + target, + ...(tools === undefined ? {} : { tools }), + }); + })); +}; + +const hookEntries = ( + entries: readonly TargetHookEntry[], + target: string, + tools: AgentBundleToolsConfig | undefined, +): readonly BundlerInspectionEntry[] => { + const outputRoot = artifactOutputToken(target); + return entries.map((entry) => rslibInspectionEntry({ + entry: { + name: entry.relativePath.replaceAll('/', '-').replace(/\.mjs$/u, ''), + outputRelativePath: entry.relativePath, + source: entry.hook.source, + sourceInputs: [], + virtualSource: entry.virtualSource, + }, + kind: 'hook', + name: entry.hook.name, + outputPath: `${target}/${entry.relativePath}`, + outputRoot, + source: entry.hook.source, + target, + ...(tools === undefined ? {} : { tools }), + })); +}; + +const mcpAppsEntry = ( + model: NormalizedPlugin, + target: string, + tools: AgentBundleToolsConfig | undefined, +): readonly BundlerInspectionEntry[] => { + const outputRoot = artifactOutputToken(target); + const apps = model.mcpApps ?? []; + const planned = planCompiledMcpApps(apps, { outDir: outputRoot, target }); + if (planned.length === 0) return []; + const sources = planned.map((app) => { + const source = apps.find((candidate) => candidate.id === app.id); + if (source === undefined) { + throw new Error(`MCP App ${JSON.stringify(app.id)} disappeared during bundler inspection.`); + } + return source; + }); + return [Object.freeze({ + bundler: 'rsbuild' as const, + config: renderConfigValue(composeMcpAppsRsbuildConfig(sources, { + outDir: outputRoot, + ...(tools === undefined ? {} : { tools }), + })), + kind: 'mcp-apps' as const, + name: 'mcp-apps', + outputPath: `${target}/mcp-apps`, + target, + })]; +}; + +const packageBuildEntries = async ( + model: NormalizedPlugin, + 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); + return planned.map((entry) => { + const bin = entry.executable; + return rslibInspectionEntry({ + entry, + kind: bin ? 'bin' : 'lib', + name: bin ? entry.name.replace(/^bin-/u, '') : entry.name, + outputPath: `${packageBuild.outputDir}/${entry.outputRelativePath}`, + outputRoot: packageBuild.outputDir, + source: entry.source, + ...(tools === undefined ? {} : { tools }), + }); + }); +}; + +const entryOrder = (left: BundlerInspectionEntry, right: BundlerInspectionEntry): number => + (left.target ?? '').localeCompare(right.target ?? '') || + left.kind.localeCompare(right.kind) || + left.name.localeCompare(right.name); + +export const composeBundlerInspection = async (options: { + readonly model: NormalizedPlugin; + readonly targets: readonly { readonly hookEntries: readonly TargetHookEntry[]; readonly name: string }[]; + readonly tools?: AgentBundleToolsConfig; +}): Promise => { + const entries: BundlerInspectionEntry[] = []; + for (const target of options.targets) { + entries.push( + ...(await scriptEntries(options.model, target.name, options.tools)), + ...(await mcpEntryEntries(options.model, target.name, options.tools)), + ...hookEntries(target.hookEntries, target.name, options.tools), + ...mcpAppsEntry(options.model, target.name, options.tools), + ); + } + entries.push(...(await packageBuildEntries(options.model, options.tools))); + return Object.freeze({ + entries: Object.freeze(entries.sort(entryOrder)), + }); +}; diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 3c944ae32..47fb20c0b 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -1,4 +1,4 @@ -import { createRsbuild, mergeRsbuildConfig } from '@rsbuild/core'; +import { createRsbuild, mergeRsbuildConfig, type RsbuildConfig } from '@rsbuild/core'; import { pluginReact } from '@rsbuild/plugin-react'; import { readFile } from 'node:fs/promises'; import { extname, resolve } from 'node:path'; @@ -126,33 +126,18 @@ export const planCompiledMcpApps = ( }))); }; -export const compileMcpApps = async ( - apps: readonly NormalizedMcpApp[], - options: { - readonly cwd: string; - readonly outDir: string; - readonly target: string; - readonly tools?: AgentBundleToolsConfig; - }, -): Promise => { - const compiled = planCompiledMcpApps(apps, { outDir: options.outDir, target: options.target }); - if (compiled.length === 0) { - return compiled; - } - - const sources = compiled.map((app) => { - const source = apps.find((candidate) => candidate.id === app.id); - if (source === undefined) { - throw new Error(`MCP App ${JSON.stringify(app.id)} disappeared during compilation planning.`); - } - return source; - }); - - // One Rsbuild instance with one environment per app compiles every view in - // a single parallel run instead of a sequential per-app build loop. The - // consumer escape hatch merges over this synthesized profile with the - // framework invariant hook appended last; the resolved-config assertions - // below bound what the hatch may change. +/** + * One Rsbuild instance with one environment per app compiles every view in + * a single parallel run instead of a sequential per-app build loop. The + * consumer escape hatch merges over this synthesized profile with the + * framework invariant hook appended last; the resolved-config assertions in + * `compileMcpApps` bound what the hatch may change. `inspect --bundler` + * surfaces exactly this composition. + */ +export const composeMcpAppsRsbuildConfig = ( + sources: readonly Pick[], + options: { readonly outDir: string; readonly tools?: AgentBundleToolsConfig }, +): RsbuildConfig => { const profile = { environments: Object.fromEntries(sources.map((source) => [source.name, { ...(usesReactSyntax(source.source) ? { plugins: [pluginReact()] } : {}), @@ -179,20 +164,45 @@ export const compileMcpApps = async ( server: { publicDir: false }, splitChunks: false, }; + const enforceInvariants = (config: { output: { asyncChunks?: boolean } }): void => { + config.output.asyncChunks = false; + }; + return mergeRsbuildConfig( + profile as never, + ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild as never]), + ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } } as never]), + { tools: { rspack: enforceInvariants } } as never, + ) as RsbuildConfig; +}; + +export const compileMcpApps = async ( + apps: readonly NormalizedMcpApp[], + options: { + readonly cwd: string; + readonly outDir: string; + readonly target: string; + readonly tools?: AgentBundleToolsConfig; + }, +): Promise => { + const compiled = planCompiledMcpApps(apps, { outDir: options.outDir, target: options.target }); + if (compiled.length === 0) { + return compiled; + } + + const sources = compiled.map((app) => { + const source = apps.find((candidate) => candidate.id === app.id); + if (source === undefined) { + throw new Error(`MCP App ${JSON.stringify(app.id)} disappeared during compilation planning.`); + } + return source; + }); + const rsbuild = await createRsbuild({ cwd: options.cwd, - config: mergeRsbuildConfig( - profile as never, - ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild as never]), - ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } } as never]), - { - tools: { - rspack: (config: { output: { asyncChunks?: boolean } }) => { - config.output.asyncChunks = false; - }, - }, - } as never, - ), + config: composeMcpAppsRsbuildConfig(sources, { + outDir: options.outDir, + ...(options.tools === undefined ? {} : { tools: options.tools }), + }), }); const inspection = await rsbuild.inspectConfig({ mode: 'production' }); assertResolvedViewConfig(inspection, compiled.map((app) => app.name), options.outDir); diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 1f3eca285..e25a7afd8 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -45,7 +45,7 @@ const relativeSourceInputs = (projectRoot: string, inputs: readonly string[]): r Object.freeze([...new Set(inputs.map((input) => toPosixRelative(projectRoot, assertInside(projectRoot, input))))] .sort((left, right) => left.localeCompare(right))); -interface PlannedPackageEntry extends RslibEntry { +export interface PlannedPackageEntry extends RslibEntry { readonly executable: boolean; } @@ -87,7 +87,7 @@ const synthesizeDtsTsconfig = async (options: { }; }; -const planPackageEntries = async ( +export const planPackageEntries = async ( model: NormalizedPlugin, dtsTsconfigPath: string | undefined, ): Promise => { diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 1dd3ca68a..6abf2c46e 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -95,6 +95,86 @@ const assertExecutableConfig = ( } }; +/** + * Composes the full Rslib lib config for one synthesized entry: the + * framework profile, the consumer `tools` escape hatch merged over it + * (Rslib's "raw user config highest" priority), and the invariant enforcer + * hook appended last. `buildWithRslib` lowers exactly this composition and + * `inspect --bundler` surfaces it, so the two can never drift. + */ +export const composeEntryLibConfig = ( + entry: RslibEntry, + options: { readonly outputRoot: string; readonly tools?: AgentBundleToolsConfig }, +): LibConfig => { + const virtualSource = entry.virtualSource; + const virtualModules = (entry.virtualModules ?? []).map((module, index) => ({ + ...module, + path: resolve(options.outputRoot, '.agent-bundle-virtual', `${entry.name}-${index}.mjs`), + })); + const hasVirtualModules = virtualSource !== undefined || virtualModules.length > 0; + const aliases = entry.aliases ?? {}; + const enforceInvariants = (config: { + output: { asyncChunks?: boolean }; + plugins: unknown[]; + resolve: { alias?: Record }; + }): void => { + config.output.asyncChunks = false; + if (hasVirtualModules || Object.keys(aliases).length > 0) { + config.resolve.alias = { + ...config.resolve.alias, + ...aliases, + ...Object.fromEntries(virtualModules.map((module) => [module.name, module.path])), + }; + } + if (hasVirtualModules) { + config.plugins.push(new rspack.experiments.VirtualModulesPlugin({ + ...(virtualSource === undefined ? {} : { [entryAnchor]: virtualSource }), + ...Object.fromEntries(virtualModules.map((module) => [module.path, module.source])), + })); + } + }; + const profile: RslibLibConfig = { + id: `agent-bundle-${entry.name}`, + autoExternal: false, + ...(entry.banner === undefined ? {} : { banner: { js: entry.banner } }), + bundle: true, + dts: entry.dts === true, + format: 'esm', + // Copied projects can share one node_modules tree, so a cache keyed + // by this stable library id would give concurrent builds one lock. + performance: { + buildCache: false, + }, + // Rsbuild 2.x deprecated performance.chunkSplit 'all-in-one'; the + // documented migration is top-level splitChunks: false, which also + // guards against the node-target splitting default added in v2.2. + splitChunks: false, + syntax: 'es2022', + output: { + cleanDistPath: false, + distPath: { root: options.outputRoot }, + filename: { js: entry.outputRelativePath }, + filenameHash: false, + legalComments: 'none', + minify: false, + sourceMap: false, + target: 'node', + }, + source: { + entry: { + [entry.name]: virtualSource === undefined ? entry.source : entryAnchor, + }, + ...(entry.tsconfigPath === undefined ? {} : { tsconfigPath: entry.tsconfigPath }), + }, + }; + return mergeRsbuildConfig( + profile as never, + ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild as never]), + ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } } as never]), + { tools: { rspack: enforceInvariants } } as never, + ) as RslibLibConfig; +}; + export const buildWithRslib = async (options: { readonly cwd: string; readonly entries: readonly RslibEntry[]; @@ -115,78 +195,10 @@ export const buildWithRslib = async (options: { cwd: options.cwd, config: { logLevel: options.logLevel ?? 'silent', - lib: options.entries.map((entry) => { - const virtualSource = entry.virtualSource; - const virtualModules = (entry.virtualModules ?? []).map((module, index) => ({ - ...module, - path: resolve(options.outputRoot, '.agent-bundle-virtual', `${entry.name}-${index}.mjs`), - })); - const hasVirtualModules = virtualSource !== undefined || virtualModules.length > 0; - const aliases = entry.aliases ?? {}; - const enforceInvariants = (config: { - output: { asyncChunks?: boolean }; - plugins: unknown[]; - resolve: { alias?: Record }; - }): void => { - config.output.asyncChunks = false; - if (hasVirtualModules || Object.keys(aliases).length > 0) { - config.resolve.alias = { - ...config.resolve.alias, - ...aliases, - ...Object.fromEntries(virtualModules.map((module) => [module.name, module.path])), - }; - } - if (hasVirtualModules) { - config.plugins.push(new rspack.experiments.VirtualModulesPlugin({ - ...(virtualSource === undefined ? {} : { [entryAnchor]: virtualSource }), - ...Object.fromEntries(virtualModules.map((module) => [module.path, module.source])), - })); - } - }; - const profile: RslibLibConfig = { - id: `agent-bundle-${entry.name}`, - autoExternal: false, - ...(entry.banner === undefined ? {} : { banner: { js: entry.banner } }), - bundle: true, - dts: entry.dts === true, - format: 'esm', - // Copied projects can share one node_modules tree, so a cache keyed - // by this stable library id would give concurrent builds one lock. - performance: { - buildCache: false, - }, - // Rsbuild 2.x deprecated performance.chunkSplit 'all-in-one'; the - // documented migration is top-level splitChunks: false, which also - // guards against the node-target splitting default added in v2.2. - splitChunks: false, - syntax: 'es2022', - output: { - cleanDistPath: false, - distPath: { root: options.outputRoot }, - filename: { js: entry.outputRelativePath }, - filenameHash: false, - legalComments: 'none', - minify: false, - sourceMap: false, - target: 'node', - }, - source: { - entry: { - [entry.name]: virtualSource === undefined ? entry.source : entryAnchor, - }, - ...(entry.tsconfigPath === undefined ? {} : { tsconfigPath: entry.tsconfigPath }), - }, - }; - // The escape hatch merges over the synthesized profile (Rslib's - // "raw user config highest" priority); the invariant enforcer hook is - // appended last, and the post-resolution assertions bound the hatch. - return mergeRsbuildConfig( - profile as never, - ...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild as never]), - ...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } } as never]), - { tools: { rspack: enforceInvariants } } as never, - ) as RslibLibConfig; - }), + lib: options.entries.map((entry) => composeEntryLibConfig(entry, { + outputRoot: options.outputRoot, + ...(options.tools === undefined ? {} : { tools: options.tools }), + })), }, }); diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 691439888..8c7740536 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -65,6 +65,7 @@ interface EvalCommandOptions extends SourceCommandOptions { } interface InspectCommandOptions { + readonly bundler?: boolean; readonly config?: string; readonly hooks?: boolean; readonly json?: boolean; @@ -207,6 +208,12 @@ const writeHumanInspect = (output: Output, result: Awaited plan.target).join(', ')}\n`); }; @@ -267,6 +274,11 @@ const writeHumanEvalComparison = (output: Output, result: Awaited>): void => { + // Errors abort the command before this writer runs, so any diagnostics + // reaching it are informational nudges or warnings worth surfacing. + for (const diagnostic of result.diagnostics) { + output.write(`${diagnostic.code} (${diagnostic.severity}): ${diagnostic.message}\n`); + } output.write(result.diagnostics.some((diagnostic) => diagnostic.severity === 'error') ? `Validation reported ${result.diagnostics.length} diagnostic(s)\n` : 'Validation succeeded\n'); @@ -395,15 +407,18 @@ 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('--hooks', 'Include the hook focus') .option('--skills', 'Include the skill focus'); inspectCommand.action(async (options: InspectCommandOptions) => { - if (options.hooks === true && options.skills === true) { + const focuses = [options.bundler, options.hooks, options.skills].filter((focus) => focus === true); + if (focuses.length > 1) { throw new TypeError('Choose at most one inspect focus.'); } const { inspect } = await import('./api.ts'); const result = await inspect({ ...inspectProjectOptions(options), + ...(options.bundler === true ? { focus: 'bundler' as const } : {}), ...(options.hooks === true ? { focus: 'hooks' as const } : {}), ...(options.skills === true ? { focus: 'skills' as const } : {}), ...(options.target === undefined ? {} : { target: options.target }), diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 393e5acba..cab329de8 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -106,6 +106,14 @@ export const conventionalMcpEntrySource = (root: string, serverName: string): st ? conventionalEntryAt(root, 'src', 'mcp', serverName) : undefined; +/** The `src/cli.ts` convention: the package bin entry when config is silent. */ +export const conventionalCliEntrySource = (root: string): string | undefined => + conventionalEntryAt(root, 'src', 'cli'); + +/** The `src/index.ts` convention: the package library entry when config is silent. */ +export const conventionalIndexEntrySource = (root: string): string | undefined => + conventionalEntryAt(root, 'src', 'index'); + const safePackageOutputName = (name: string): boolean => /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(name); @@ -136,7 +144,7 @@ const normalizeBinEntries = ( }; }); } - const conventional = conventionalEntryAt(root, 'src', 'cli'); + const conventional = conventionalCliEntrySource(root); if (conventional === undefined || !safePackageOutputName(config.plugin.name)) return []; return [{ id: `bin:${config.plugin.name}`, @@ -165,7 +173,7 @@ const normalizeLibEntry = ( source, }; } - const conventional = conventionalEntryAt(root, 'src', 'index'); + const conventional = conventionalIndexEntrySource(root); if (conventional === undefined) return undefined; return { dts: true, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index d459b2a79..d61a82768 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1,6 +1,7 @@ -import { existsSync, realpathSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { basename, extname, isAbsolute, posix, relative, resolve, sep } from 'node:path'; +import { scanEntryExportsSource } from '../build/entry-exports.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; import { unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; @@ -22,7 +23,11 @@ import type { NormalizationTargetRegistry, NormalizedPlugin, } from '../core/types.ts'; -import { conventionalMcpEntrySource } from './normalize.ts'; +import { + conventionalCliEntrySource, + conventionalIndexEntrySource, + conventionalMcpEntrySource, +} from './normalize.ts'; import type { DiscoveredProject } from './discover.ts'; import type { LoadedConfig } from './load.ts'; import type { SkillDocument } from './skill.ts'; @@ -35,6 +40,18 @@ const sourceDiagnostic = ( sourcePath: string, ): Diagnostic => ({ code, message, severity: 'error', sourcePath }); +/** + * Informational migration nudges (AB473x): they surface pre-convention + * patterns the entry conventions now replace, and they must never gate a + * build — migrations stay optional, so the severity is always `info`. + */ +const nudgeDiagnostic = ( + code: string, + message: string, + sourcePath: string, + recovery: string, +): Diagnostic => ({ code, message, recovery, severity: 'info', sourcePath }); + const hookEvents: readonly CanonicalHookEvent[] = [ 'sessionStart', 'beforeTool', @@ -531,6 +548,41 @@ const validateMcpApps = ( return diagnostics; }; +const relativePosix = (root: string, path: string): string => + relative(root, path).replaceAll('\\', '/'); + +/** + * AB4730: a local stdio entry whose module never default-exports a factory + * is self-connecting, so the build cannot wrap it in the framework stdio + * lifecycle shell. The detection is the same static export scan the build + * uses to decide the wrap, so the nudge and the build always agree. + */ +const selfConnectingEntryNudge = ( + name: string, + entry: string | undefined, + conventionalEntry: string | undefined, + loaded: LoadedConfig, +): Diagnostic[] => { + const source = entry !== undefined + ? nonemptyString(entry) && localEntryExists(loaded.context.projectRoot, entry) + ? resolve(loaded.context.projectRoot, entry) + : undefined + : conventionalEntry; + if (source === undefined || !bundleScriptExtensions.has(extname(source).toLowerCase())) return []; + try { + if (scanEntryExportsSource(readFileSync(source, 'utf8')).hasDefaultExport) return []; + } catch { + // An unreadable entry is already reported by the existence diagnostics. + return []; + } + return [nudgeDiagnostic( + 'AB4730', + `MCP server ${JSON.stringify(name)} stdio entry is self-connecting; a default-exported server factory would receive the framework stdio lifecycle shell.`, + source, + 'Optional: default-export a server factory from the entry module to adopt the framework lifecycle; self-connecting entries keep their current behavior.', + )]; +}; + const validateMcpServer = ( name: string, value: unknown, @@ -569,6 +621,20 @@ const validateMcpServer = ( )); return diagnostics; } + if (variants.length === 1) { + const shadowed = conventionalMcpEntrySource(loaded.context.projectRoot, name); + if ( + shadowed !== undefined && + !(typeof entry === 'string' && entry.trim().length > 0 && resolve(loaded.context.projectRoot, entry) === shadowed) + ) { + diagnostics.push(nudgeDiagnostic( + 'AB4733', + `MCP server ${JSON.stringify(name)} has the conventional stdio entry ${JSON.stringify(relativePosix(loaded.context.projectRoot, shadowed))}, but explicit configuration points elsewhere; the conventional file is shadowed.`, + shadowed, + 'Optional: drop the explicit entry, command, or url to adopt the conventional stdio entry, or remove the shadowed file to silence this nudge.', + )); + } + } diagnostics.push(...validateStringList(server.targets, 'targets', 'AB4305', loaded)); if (entry !== undefined || conventionalEntry !== undefined) { @@ -588,6 +654,7 @@ const validateMcpServer = ( } diagnostics.push(...validateStringList(server.args, 'args', 'AB4311', loaded)); diagnostics.push(...validateStringRecord(server.env, 'env', 'AB4312', loaded)); + diagnostics.push(...selfConnectingEntryNudge(name, entry, conventionalEntry, loaded)); return diagnostics; } @@ -820,6 +887,55 @@ const validateLib = (loaded: LoadedConfig): Diagnostic[] => { return diagnostics; }; +/** + * AB4731 / AB4732: a conventional package entry file exists but explicit + * `bin` / `lib` configuration never references it, so the convention is + * silently shadowed — a confusable state worth one informational nudge. + * `bin: false` / `lib: false` are deliberate opt-outs and stay silent. + */ +const packageConventionShadowNudges = (loaded: LoadedConfig): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const root = loaded.context.projectRoot; + + const bin = loaded.config.bin; + if (bin !== undefined && bin !== false && isRecord(bin)) { + const conventional = conventionalCliEntrySource(root); + const referenced = conventional !== undefined && Object.values(bin).some((declaration) => { + const entry = typeof declaration === 'string' + ? declaration + : isRecord(declaration) ? (declaration as AgentBundleBinEntry).entry : undefined; + return nonemptyString(entry) && resolve(root, entry) === conventional; + }); + if (conventional !== undefined && !referenced) { + diagnostics.push(nudgeDiagnostic( + 'AB4731', + `${relativePosix(root, conventional)} is present but explicit bin configuration does not reference it; the conventional package bin is shadowed.`, + conventional, + 'Optional: remove the explicit bin configuration to adopt the src/cli.ts convention, reference the file from a bin entry, or remove the file to silence this nudge.', + )); + } + } + + const lib = loaded.config.lib; + if (lib !== undefined && lib !== false && (typeof lib === 'string' || isRecord(lib))) { + const conventional = conventionalIndexEntrySource(root); + const entry = typeof lib === 'string' ? lib : (lib as AgentBundleLibEntry).entry; + if ( + conventional !== undefined && + !(nonemptyString(entry) && resolve(root, entry) === conventional) + ) { + diagnostics.push(nudgeDiagnostic( + 'AB4732', + `${relativePosix(root, conventional)} is present but explicit lib configuration does not reference it; the conventional library entry is shadowed.`, + conventional, + 'Optional: remove the explicit lib configuration to adopt the src/index.ts convention, point it at the file, or remove the file to silence this nudge.', + )); + } + } + + return diagnostics; +}; + const isRspackHatchValue = (value: unknown): boolean => typeof value === 'function' || isRecord(value); @@ -918,6 +1034,7 @@ export const validateSource = ( diagnostics.push(...validateRuntime(loaded)); diagnostics.push(...validateScripts(loaded, registry)); diagnostics.push(...validateTools(loaded)); + diagnostics.push(...packageConventionShadowNudges(loaded)); return diagnostics; }; diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index 4a02a354c..c28e12e62 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -7,6 +7,7 @@ import { DiagnosticService, type DiagnosticReport } from './diagnostic-service.t import { acquireDevLock, type DevLockOptions } from './dev-lock.ts'; import { EpochStore } from './epoch-store.ts'; import { ProjectEventHub } from './events.ts'; +import { DevPackageBuildService, type DevPackageBuilder } from './package-build-service.ts'; import { ProjectService, type PreparedProject, type ProjectCommand } from './project-service.ts'; import { ProjectWatcher, type ProjectWatcherOptions } from './watcher.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from '../config/ignore.ts'; @@ -82,6 +83,8 @@ export interface DevCoordinatorOptions { readonly ignoredPaths?: readonly string[]; readonly onPreparedProject?: (prepared: PreparedProject) => Promise; readonly outputPaths?: readonly string[]; + /** Rebuilds the framework-owned package build (bin/lib) after successful artifact rebuilds. */ + readonly packageBuildService?: DevPackageBuilder; readonly prepareCommand?: 'build' | 'dev'; readonly projectService?: ProjectPreparer; readonly root: string; @@ -199,6 +202,7 @@ export class DevCoordinator { readonly #ignoredPaths: readonly string[]; readonly #outputPaths: readonly string[]; readonly #onPreparedProject: ((prepared: PreparedProject) => Promise) | undefined; + readonly #packageBuildService: DevPackageBuilder; readonly #prepareCommand: 'build' | 'dev'; readonly #projectService: ProjectPreparer; readonly #root: string; @@ -236,6 +240,7 @@ export class DevCoordinator { this.#now = options.now ?? (() => new Date()); this.#nextPreparedProject = options.initialPreparedProject; this.#onPreparedProject = options.onPreparedProject; + this.#packageBuildService = options.packageBuildService ?? new DevPackageBuildService(); this.#outputPaths = Object.freeze([...new Set([ ...(options.outputPaths ?? ['dist']), ...(options.initialPreparedProject?.outputRoots ?? []), @@ -484,7 +489,13 @@ export class DevCoordinator { phaseDiagnostic('artifact', error), ]); } - const diagnostics = freezeDiagnostics([...lintDiagnostics, ...result.diagnostics]); + // The package build (bin/lib) rebuilds inside the same serialized pass, + // after the artifact epoch committed: its failure never invalidates the + // epoch and surfaces as warning diagnostics on the succeeded attempt. + const packageDiagnostics = result.outcome === 'succeeded' + ? (await this.#packageBuildService.build(prepared, invalidation)).diagnostics + : Object.freeze([]); + const diagnostics = freezeDiagnostics([...lintDiagnostics, ...result.diagnostics, ...packageDiagnostics]); if (result.outcome === 'succeeded') { const completed: SucceededBuildAttempt = Object.freeze({ completedAt: this.#now().toISOString(), diff --git a/packages/agent-bundle/src/dev/index.ts b/packages/agent-bundle/src/dev/index.ts index 66db22ed7..672eb54ac 100644 --- a/packages/agent-bundle/src/dev/index.ts +++ b/packages/agent-bundle/src/dev/index.ts @@ -24,6 +24,13 @@ export { type DevSession, type ProjectPreparer, } from './coordinator.ts'; +export { + DevPackageBuildService, + type DevPackageBuilder, + type DevPackageBuildOutcome, + type DevPackageBuildServiceOptions, + type DevPackageBuildState, +} from './package-build-service.ts'; export { ProjectWatcher, type ProjectWatcherOptions, diff --git a/packages/agent-bundle/src/dev/package-build-service.ts b/packages/agent-bundle/src/dev/package-build-service.ts new file mode 100644 index 000000000..d8e6f0fd4 --- /dev/null +++ b/packages/agent-bundle/src/dev/package-build-service.ts @@ -0,0 +1,183 @@ +import { rm, rmdir } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; + +import { buildPackageOutputs } from '../build/package-build.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { digest } from '../core/digest.ts'; +import type { PreparedProject } from './project-service.ts'; +import type { Invalidation } from './types.ts'; + +/** + * Dev-watch parity for the framework-owned package build (RFC #50 §3.5): + * `agent-bundle dev` rebuilds `dist/` bin and lib outputs inside the same + * debounced, serialized rebuild pass that produces artifact epochs. The + * incremental boundary is provenance-based: a rebuild is skipped only when + * no invalidated path was an input of the previous successful package build + * (every bundled module, recorded from bundler stats) and the rebuild + * identity — the normalized `bin`/`lib` declaration plus the `tools` escape + * hatch (functions compared by source text) — is unchanged. Changes the + * bundler cannot attribute to a tracked input — the project configuration + * file, `package.json`, `tsconfig.json`, manual and initial invalidations, + * or any previous failure — always rebuild. + * + * When every package entry disappears within a live session (entries removed + * or opted out with `bin: false` / `lib: false`), the outputs this session + * previously published are removed so `dist/` never serves executables the + * configuration no longer declares. Outputs published by earlier sessions + * are untouched, matching `agent-bundle build`. + * + * A package build failure never invalidates the artifact epoch that already + * committed; it surfaces as one AB7103 warning on the succeeded attempt. + */ + +export type DevPackageBuildState = 'absent' | 'built' | 'failed' | 'removed' | 'skipped'; + +export interface DevPackageBuildOutcome { + readonly diagnostics: readonly Diagnostic[]; + readonly state: DevPackageBuildState; +} + +export interface DevPackageBuilder { + build(prepared: PreparedProject, invalidation: Invalidation): Promise; +} + +export interface DevPackageBuildServiceOptions { + /** Injectable only for deterministic unit tests. */ + readonly buildOutputs?: typeof buildPackageOutputs; +} + +/** Files that change the package build without appearing in bundle provenance. */ +const configurationInputs = new Set(['package.json', 'tsconfig.json']); + +const emptyDiagnostics: readonly Diagnostic[] = Object.freeze([]); + +const outcome = ( + state: DevPackageBuildState, + diagnostics: readonly Diagnostic[] = emptyDiagnostics, +): DevPackageBuildOutcome => Object.freeze({ diagnostics, state }); + +const warning = (message: string, sourcePath: string): Diagnostic => Object.freeze({ + code: 'AB7103', + message, + severity: 'warning' as const, + sourcePath, +}); + +/** + * The `tools` hatch participates in the rebuild identity so a hatch edit in + * the configuration graph rebuilds `dist/` even when no tracked source input + * changed. Functions (rspack mutators) compare by their source text, which + * the fresh per-preparation config evaluation keeps current. + */ +const toolsIdentity = (value: unknown): unknown => { + if (typeof value === 'function') return String(value); + if (Array.isArray(value)) return value.map((item) => toolsIdentity(item)); + if (typeof value === 'object' && value !== null) { + return Object.fromEntries(Object.entries(value) + .map(([key, item]) => [key, toolsIdentity(item)])); + } + return value; +}; + +const relativePosix = (root: string, path: string): string => + relative(root, path).replaceAll('\\', '/'); + +export class DevPackageBuildService implements DevPackageBuilder { + readonly #buildOutputs: typeof buildPackageOutputs; + #last: Readonly<{ + identity: string; + inputs: ReadonlySet; + succeeded: boolean; + }> | undefined; + /** The outputs this session last published, for removal when the package build disappears. */ + #published: Readonly<{ outputRoot: string; paths: readonly string[] }> | undefined; + + constructor(options: DevPackageBuildServiceOptions = {}) { + this.#buildOutputs = options.buildOutputs ?? buildPackageOutputs; + } + + async build(prepared: PreparedProject, invalidation: Invalidation): Promise { + const model = prepared.model; + if (model?.packageBuild === undefined) { + this.#last = undefined; + return this.#removePublishedOutputs(prepared.configPath); + } + const identity = digest({ + packageBuild: model.packageBuild, + tools: prepared.tools === undefined ? null : toolsIdentity(prepared.tools), + }); + if (!this.#shouldRebuild(identity, invalidation, prepared)) { + return outcome('skipped'); + } + try { + const result = await this.#buildOutputs({ + model, + projectRoot: prepared.root, + ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), + }); + if (result === undefined) { + this.#last = undefined; + return this.#removePublishedOutputs(prepared.configPath); + } + this.#last = Object.freeze({ + identity, + inputs: new Set(result.files.flatMap((file) => file.sourceInputs)), + succeeded: true, + }); + this.#published = Object.freeze({ + outputRoot: result.outputRoot, + paths: Object.freeze(result.files.map((file) => file.path)), + }); + return outcome('built'); + } catch (error) { + this.#last = Object.freeze({ identity, inputs: new Set(), succeeded: false }); + return outcome('failed', Object.freeze([warning( + `Package build (bin/lib) failed during development rebuild: ${ + error instanceof Error ? error.message : String(error) + }`, + prepared.configPath, + )])); + } + } + + #shouldRebuild(identity: string, invalidation: Invalidation, prepared: PreparedProject): boolean { + const last = this.#last; + if (last === undefined || !last.succeeded || last.identity !== identity) return true; + if (invalidation.reason !== 'source-change') return true; + const configPath = relativePosix(prepared.root, prepared.configPath); + return invalidation.paths.some((path) => + path === configPath || last.inputs.has(path) || configurationInputs.has(path)); + } + + async #removePublishedOutputs(configPath: string): Promise { + const published = this.#published; + if (published === undefined) return outcome('absent'); + this.#published = undefined; + try { + for (const path of published.paths) { + await rm(join(published.outputRoot, path), { force: true }); + } + // Prune now-empty directories, deepest first; a directory that still + // holds files another producer wrote simply stays. + const directories = [...new Set(published.paths + .map((path) => dirname(path)) + .filter((directory) => directory !== '.'))] + .sort((left, right) => right.length - left.length); + for (const directory of [...directories.map((entry) => join(published.outputRoot, entry)), published.outputRoot]) { + try { + await rmdir(directory); + } catch { + // Nonempty or already gone: both fine. + } + } + return outcome('removed'); + } catch (error) { + return outcome('removed', Object.freeze([warning( + `Unable to remove stale package build outputs: ${ + error instanceof Error ? error.message : String(error) + }`, + configPath, + )])); + } + } +} diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index a858d0b64..0d3b36410 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -416,6 +416,67 @@ it('reports an unselected inspect target on JSON and human output', async () => } }, 30_000); +it('dumps the synthesized bundler configuration with inspect --bundler', async () => { + const project = await createCliProject(); + try { + await mkdir(join(project.root, 'src'), { recursive: true }); + await writeFile( + join(project.root, 'agent-bundle.config.ts'), + [ + 'export default {', + " plugin: { name: 'cli-fixture', version: '1.0.0' },", + " targets: ['portable'],", + " scripts: { tool: './src/tool.ts' },", + " tools: { rspack: { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } } },", + '};', + '', + ].join('\n'), + ); + await writeFile(join(project.root, 'src', 'tool.ts'), 'export const main = async () => 0;\n'); + + const json = await runSourceCliWithOutput(['inspect', '--root', project.root, '--bundler', '--json']); + expect(json).toMatchObject({ code: 0, stderr: '' }); + const document = JSON.parse(json.stdout) as { + readonly selected: { + readonly bundler: { + readonly entries: readonly { + readonly config: { readonly tools: { readonly rspack: readonly unknown[] } }; + readonly kind: string; + readonly name: string; + }[]; + }; + }; + }; + const script = document.selected.bundler.entries.find((entry) => entry.kind === 'script'); + expect(script).toMatchObject({ + config: { + output: { distPath: { root: '/portable' } }, + tools: { + rspack: [ + { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }, + '[function enforceInvariants]', + ], + }, + }, + name: 'tool', + }); + + const repeated = await runSourceCliWithOutput(['inspect', '--root', project.root, '--bundler', '--json']); + expect(repeated.stdout).toBe(json.stdout); + + 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]'); + + const ambiguous = await runSourceCliWithOutput(['inspect', '--root', project.root, '--bundler', '--skills']); + expect(ambiguous.code).toBe(1); + expect(JSON.parse(ambiguous.stderr)).toMatchObject([{ code: 'AB5000', severity: 'error' }]); + } finally { + await rm(resolve(project.root, '..'), { force: true, recursive: true }); + } +}, 30_000); + it('reports source validation diagnostics on stderr before staging an artifact', async () => { await buildCliPackage(); const project = await createCliProject(); diff --git a/packages/agent-bundle/tests/dev-coordinator.test.ts b/packages/agent-bundle/tests/dev-coordinator.test.ts index 9b9ad483c..0dedfeaa1 100644 --- a/packages/agent-bundle/tests/dev-coordinator.test.ts +++ b/packages/agent-bundle/tests/dev-coordinator.test.ts @@ -195,6 +195,68 @@ it('serializes a running build and coalesces all concurrent invalidations into o } }); +it('runs the package build inside the rebuild pass and surfaces its warnings on succeeded attempts', async () => { + const root = await createProject(); + const packageCalls: (readonly string[])[] = []; + let artifactOutcome: 'failed' | 'succeeded' = 'succeeded'; + let packageDiagnostics: readonly { code: string; message: string; severity: 'warning' }[] = []; + let builds = 0; + + try { + const coordinator = new DevCoordinator({ + acquireLock: async () => ({ close: async () => undefined }), + artifactService: { + build: async (prepared) => { + builds += 1; + return artifactOutcome === 'succeeded' + ? succeeded(epochFor(root, `epoch-${builds}`, prepared.source.revision ?? 'missing')) + : failed(); + }, + }, + diagnosticService: { + close: async () => undefined, + lint: async (paths): Promise => ({ diagnostics: [], paths }), + }, + epochStore: new EpochStore({ projectRoot: root }), + packageBuildService: { + build: async (_prepared, invalidated) => { + packageCalls.push(invalidated.paths); + return { diagnostics: packageDiagnostics, state: 'built' }; + }, + }, + projectService: new ProjectService({ root }), + root, + }); + + await coordinator.start(); + expect(packageCalls).toEqual([[]]); + + packageDiagnostics = [{ + code: 'AB7103', + message: 'Package build (bin/lib) failed during development rebuild: sentinel.', + severity: 'warning', + }]; + const warned = await coordinator.rebuild(invalidation(['src/cli.ts'])); + expect(warned.outcome).toBe('succeeded'); + expect(packageCalls).toEqual([[], ['src/cli.ts']]); + expect(coordinator.status().build).toMatchObject({ state: 'idle' }); + expect(coordinator.status().build.lastAttempt).toMatchObject({ outcome: 'succeeded' }); + expect(coordinator.status().build.lastAttempt?.diagnostics).toContainEqual( + expect.objectContaining({ code: 'AB7103', severity: 'warning' }), + ); + + // A failed artifact build never reaches the package build. + artifactOutcome = 'failed'; + const failedRebuild = await coordinator.rebuild(invalidation(['src/broken.ts'])); + expect(failedRebuild.outcome).toBe('failed'); + expect(packageCalls).toHaveLength(2); + + await coordinator.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('queues watcher add, change, and delete paths as one rebuild during a running build', async () => { const root = await createProject(); const sourceWatcher = new EventSourceWatcher(); diff --git a/packages/agent-bundle/tests/dev-package-build-service.test.ts b/packages/agent-bundle/tests/dev-package-build-service.test.ts new file mode 100644 index 000000000..9d4e07d21 --- /dev/null +++ b/packages/agent-bundle/tests/dev-package-build-service.test.ts @@ -0,0 +1,306 @@ +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; + +import type { buildPackageOutputs, PackageBuildResult } from '../src/build/package-build.ts'; +import type { AgentBundleToolsConfig, NormalizedPlugin } from '../src/core/types.ts'; +import { DevPackageBuildService } from '../src/dev/package-build-service.ts'; +import type { PreparedProject } from '../src/dev/project-service.ts'; +import type { Invalidation } from '../src/dev/types.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +type BuildOutputs = typeof buildPackageOutputs; + +const packageBuild = (binName: string): NormalizedPlugin['packageBuild'] => ({ + bins: [{ + id: `bin:${binName}`, + name: binName, + provenance: { kind: 'conventional', sourcePath: `/project/src/cli.ts` }, + source: '/project/src/cli.ts', + }], + outputDir: 'dist', +}); + +const prepared = (options: { + readonly packageBuild?: NormalizedPlugin['packageBuild']; + readonly root?: string; + readonly tools?: PreparedProject['tools']; +} = {}): PreparedProject => ({ + configPath: `${options.root ?? '/project'}/agent-bundle.config.ts`, + diagnostics: [], + model: { + ...(options.packageBuild === undefined ? {} : { packageBuild: options.packageBuild }), + } as NormalizedPlugin, + outputRoots: [], + registry: undefined as never, + root: options.root ?? '/project', + source: { diagnostics: [], state: 'ready' }, + ...(options.tools === undefined ? {} : { tools: options.tools }), +}); + +const invalidation = ( + reason: Invalidation['reason'], + paths: readonly string[] = [], +): Invalidation => ({ + occurredAt: '2026-08-30T12:00:00.000Z', + paths, + reason, +}); + +const buildResult = (sourceInputs: readonly string[]): PackageBuildResult => ({ + files: [{ + bytes: 1, + kind: 'bundle', + path: 'bin/tool.js', + sha256: '0'.repeat(64), + sourceInputs, + }], + outputRoot: '/project/dist', +}); + +it('reports absent projects without invoking the package build', async () => { + const calls: unknown[] = []; + const service = new DevPackageBuildService({ + buildOutputs: (async (options) => { + calls.push(options); + return buildResult([]); + }) as BuildOutputs, + }); + + await expect(service.build(prepared(), invalidation('initial'))).resolves.toEqual({ + diagnostics: [], + state: 'absent', + }); + expect(calls).toHaveLength(0); +}); + +it('builds initially, skips untracked changes, and rebuilds tracked inputs', async () => { + let builds = 0; + const service = new DevPackageBuildService({ + buildOutputs: (async () => { + builds += 1; + return buildResult(['agent-bundle.config.ts', 'src/cli.ts', 'src/util.ts']); + }) as BuildOutputs, + }); + const project = prepared({ packageBuild: packageBuild('tool') }); + + await expect(service.build(project, invalidation('initial'))).resolves.toEqual({ + diagnostics: [], + state: 'built', + }); + expect(builds).toBe(1); + + await expect(service.build(project, invalidation('source-change', ['skills/review/SKILL.md']))) + .resolves.toEqual({ diagnostics: [], state: 'skipped' }); + expect(builds).toBe(1); + + await expect(service.build(project, invalidation('source-change', ['src/util.ts']))) + .resolves.toEqual({ diagnostics: [], state: 'built' }); + expect(builds).toBe(2); +}); + +it.each([ + { label: 'package.json', paths: ['package.json'] }, + { label: 'tsconfig.json', paths: ['tsconfig.json'] }, +])('always rebuilds when $label changes', async ({ paths }) => { + let builds = 0; + const service = new DevPackageBuildService({ + buildOutputs: (async () => { + builds += 1; + return buildResult(['src/cli.ts']); + }) as BuildOutputs, + }); + const project = prepared({ packageBuild: packageBuild('tool') }); + + await service.build(project, invalidation('initial')); + await expect(service.build(project, invalidation('source-change', paths))) + .resolves.toMatchObject({ state: 'built' }); + expect(builds).toBe(2); +}); + +it('rebuilds on manual invalidations and on package build identity changes', async () => { + let builds = 0; + const service = new DevPackageBuildService({ + buildOutputs: (async () => { + builds += 1; + return buildResult(['src/cli.ts']); + }) as BuildOutputs, + }); + + await service.build(prepared({ packageBuild: packageBuild('tool') }), invalidation('initial')); + await expect(service.build( + prepared({ packageBuild: packageBuild('tool') }), + invalidation('manual', []), + )).resolves.toMatchObject({ state: 'built' }); + await expect(service.build( + prepared({ packageBuild: packageBuild('renamed') }), + invalidation('source-change', ['skills/review/SKILL.md']), + )).resolves.toMatchObject({ state: 'built' }); + expect(builds).toBe(3); +}); + +it('surfaces failures as one AB7103 warning and retries on the next change', async () => { + let attempts = 0; + const service = new DevPackageBuildService({ + buildOutputs: (async () => { + attempts += 1; + if (attempts === 1) throw new Error('declaration generation failed'); + return buildResult(['src/cli.ts']); + }) as BuildOutputs, + }); + const project = prepared({ packageBuild: packageBuild('tool') }); + + await expect(service.build(project, invalidation('initial'))).resolves.toEqual({ + diagnostics: [{ + code: 'AB7103', + message: 'Package build (bin/lib) failed during development rebuild: declaration generation failed', + severity: 'warning', + sourcePath: '/project/agent-bundle.config.ts', + }], + state: 'failed', + }); + + // A failed build never records inputs, so even an untracked change retries. + await expect(service.build(project, invalidation('source-change', ['skills/review/SKILL.md']))) + .resolves.toMatchObject({ state: 'built' }); + expect(attempts).toBe(2); +}); + +it('always rebuilds when the configuration file itself changes', async () => { + let builds = 0; + const service = new DevPackageBuildService({ + buildOutputs: (async () => { + builds += 1; + return buildResult(['src/cli.ts']); + }) as BuildOutputs, + }); + const project = prepared({ packageBuild: packageBuild('tool') }); + + await service.build(project, invalidation('initial')); + await expect(service.build(project, invalidation('source-change', ['agent-bundle.config.ts']))) + .resolves.toMatchObject({ state: 'built' }); + expect(builds).toBe(2); +}); + +it('includes the tools hatch in the rebuild identity, comparing functions by source', async () => { + let builds = 0; + const service = new DevPackageBuildService({ + buildOutputs: (async () => { + builds += 1; + return buildResult(['src/cli.ts']); + }) as BuildOutputs, + }); + const build = packageBuild('tool'); + // Fresh function instances with identical source text, as a re-evaluated + // but unchanged configuration produces. + const unchangedHatch = (): AgentBundleToolsConfig => + ({ rspack: (config: { name?: string }) => { config.name = 'unchanged'; } }) as AgentBundleToolsConfig; + const editedHatch: AgentBundleToolsConfig = + ({ rspack: (config: { name?: string }) => { config.name = 'edited'; } }) as AgentBundleToolsConfig; + + await service.build(prepared({ packageBuild: build, tools: unchangedHatch() }), invalidation('initial')); + expect(builds).toBe(1); + + // An identical hatch source with an untracked change skips. + await expect(service.build( + prepared({ packageBuild: build, tools: unchangedHatch() }), + invalidation('source-change', ['skills/review/SKILL.md']), + )).resolves.toMatchObject({ state: 'skipped' }); + expect(builds).toBe(1); + + // A changed hatch rebuilds even when no tracked source input changed. + await expect(service.build( + prepared({ packageBuild: build, tools: editedHatch }), + invalidation('source-change', ['skills/review/SKILL.md']), + )).resolves.toMatchObject({ state: 'built' }); + expect(builds).toBe(2); + + // An rsbuild-fragment change rebuilds too. + await expect(service.build( + prepared({ packageBuild: build, tools: { rsbuild: { output: { legalComments: 'linked' } } } }), + invalidation('source-change', ['skills/review/SKILL.md']), + )).resolves.toMatchObject({ state: 'built' }); + expect(builds).toBe(3); +}); + +it('removes the outputs it published when the package build disappears', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-dev-package-remove-')); + roots.push(root); + const outputRoot = join(root, 'dist'); + await mkdir(join(outputRoot, 'bin'), { recursive: true }); + await writeFile(join(outputRoot, 'bin', 'tool.js'), '#!/usr/bin/env node\n'); + await writeFile(join(outputRoot, 'index.js'), 'export {};\n'); + await writeFile(join(outputRoot, 'index.d.ts'), 'export {};\n'); + // A file this session never published must survive the cleanup. + await writeFile(join(outputRoot, 'foreign.txt'), 'not ours\n'); + + const service = new DevPackageBuildService({ + buildOutputs: (async () => ({ + files: [ + { bytes: 1, kind: 'bundle' as const, path: 'bin/tool.js', sha256: '0'.repeat(64), sourceInputs: ['src/cli.ts'] }, + { bytes: 1, kind: 'bundle' as const, path: 'index.js', sha256: '0'.repeat(64), sourceInputs: ['src/index.ts'] }, + { bytes: 1, kind: 'generated' as const, path: 'index.d.ts', sha256: '0'.repeat(64), sourceInputs: ['src/index.ts'] }, + ], + outputRoot, + })) as BuildOutputs, + }); + + await expect(service.build( + prepared({ packageBuild: packageBuild('tool'), root }), + invalidation('initial'), + )).resolves.toMatchObject({ state: 'built' }); + + await expect(service.build(prepared({ root }), invalidation('source-change', ['agent-bundle.config.ts']))) + .resolves.toEqual({ diagnostics: [], state: 'removed' }); + expect((await readdir(outputRoot)).sort()).toEqual(['foreign.txt']); + + // With nothing published, a package-less project is simply absent. + await expect(service.build(prepared({ root }), invalidation('source-change', []))) + .resolves.toEqual({ diagnostics: [], state: 'absent' }); +}); + +it('prunes the output root entirely when it only held published outputs', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-dev-package-prune-')); + roots.push(root); + const outputRoot = join(root, 'dist'); + await mkdir(join(outputRoot, 'bin'), { recursive: true }); + await writeFile(join(outputRoot, 'bin', 'tool.js'), '#!/usr/bin/env node\n'); + + const service = new DevPackageBuildService({ + buildOutputs: (async () => ({ + files: [ + { bytes: 1, kind: 'bundle' as const, path: 'bin/tool.js', sha256: '0'.repeat(64), sourceInputs: ['src/cli.ts'] }, + ], + outputRoot, + })) as BuildOutputs, + }); + + await service.build(prepared({ packageBuild: packageBuild('tool'), root }), invalidation('initial')); + await expect(service.build(prepared({ root }), invalidation('manual', []))) + .resolves.toEqual({ diagnostics: [], state: 'removed' }); + expect((await readdir(root)).includes('dist')).toBe(false); +}); + +it('passes the prepared tools escape hatch through to the package build', async () => { + const received: unknown[] = []; + const tools = { rsbuild: { output: { legalComments: 'linked' as const } } }; + const service = new DevPackageBuildService({ + buildOutputs: (async (options) => { + received.push(options.tools); + return buildResult(['src/cli.ts']); + }) as BuildOutputs, + }); + + await service.build( + prepared({ packageBuild: packageBuild('tool'), tools }), + invalidation('initial'), + ); + expect(received).toEqual([tools]); +}); diff --git a/packages/agent-bundle/tests/dev-package-build.test.ts b/packages/agent-bundle/tests/dev-package-build.test.ts new file mode 100644 index 000000000..6c710a41d --- /dev/null +++ b/packages/agent-bundle/tests/dev-package-build.test.ts @@ -0,0 +1,83 @@ +import { readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { DevCoordinator, ProjectService } from '../src/dev/index.ts'; +import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; + +/** + * End-to-end dev-watch parity for the package build (RFC #50 §3.5): a real + * DevCoordinator rebuild pass — real project service, artifact service, and + * package build — writes `dist/` bin outputs, skips the package build when + * no tracked input changed, and rebuilds it when the bin entry changes. + */ +it('rebuilds the package build inside the dev loop when its entries change', async () => { + const fixture = await createProjectFixture({ + config: [ + 'export default {', + " plugin: { name: 'dev-package-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + files: { + 'package.json': '{"type":"module"}\n', + 'src/cli.ts': 'export const main = async () => 0;\n', + }, + prefix: 'agent-bundle-dev-package-', + }); + const root = fixture.root; + const binOutput = join(root, 'dist', 'bin', 'dev-package-fixture.js'); + const coordinator = new DevCoordinator({ + projectService: new ProjectService({ root }), + root, + }); + + try { + await coordinator.start(); + expect(coordinator.status().build).toMatchObject({ state: 'idle' }); + expect(coordinator.status().build.lastAttempt).toMatchObject({ outcome: 'succeeded' }); + + const initial = await readFile(binOutput, 'utf8'); + expect(initial.startsWith('#!/usr/bin/env node')).toBe(true); + const initialStat = await stat(binOutput); + + // An untracked change rebuilds the artifact but skips the package build. + await writeFile(join(root, 'notes.md'), 'untracked change\n'); + const skipped = await coordinator.rebuild({ + occurredAt: new Date().toISOString(), + paths: ['notes.md'], + reason: 'source-change', + }); + expect(skipped.outcome).toBe('succeeded'); + expect((await stat(binOutput)).mtimeMs).toBe(initialStat.mtimeMs); + + // Changing the bin entry rebuilds the published dist output. + await writeFile( + join(root, 'src', 'cli.ts'), + "export const main = async () => { console.error('rebuilt-marker'); return 0; };\n", + ); + const rebuilt = await coordinator.rebuild({ + occurredAt: new Date().toISOString(), + paths: ['src/cli.ts'], + reason: 'source-change', + }); + expect(rebuilt.outcome).toBe('succeeded'); + expect(await readFile(binOutput, 'utf8')).toContain('rebuilt-marker'); + + // Removing the last package entry removes the outputs this session published. + await rm(join(root, 'src', 'cli.ts')); + const removed = await coordinator.rebuild({ + occurredAt: new Date().toISOString(), + paths: ['src/cli.ts'], + reason: 'source-change', + }); + expect(removed.outcome).toBe('succeeded'); + expect(existsSync(binOutput)).toBe(false); + } finally { + await coordinator.close(); + await removeProjectFixture(root); + } +}, 120_000); diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts new file mode 100644 index 000000000..8a8ff754c --- /dev/null +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -0,0 +1,181 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { inspect, type BundlerInspectionEntry, type ReadyInspectResult } from '../src/api.ts'; +import { stableJson } from '../src/core/digest.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +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 Promise.all([ + writeFile(join(root, 'package.json'), '{"type":"module"}\n'), + writeFile( + join(root, 'agent-bundle.config.ts'), + [ + 'export default {', + " plugin: { name: 'bundler-fixture', version: '1.0.0' },", + " targets: ['portable'],", + " scripts: { tool: './src/tool.ts' },", + ' mcp: {', + ' servers: {', + ' curator: {', + ' apps: {', + " dashboard: { entry: './src/view.tsx', resourceUri: 'ui://bundler-fixture/dashboard' },", + ' },', + ' },', + ' },', + ' },', + ' tools: {', + " rsbuild: { output: { legalComments: 'linked' } },", + " rspack: { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } },", + ' },', + '};', + '', + ].join('\n'), + ), + writeFile(join(root, 'src', 'tool.ts'), 'export const main = async () => 0;\n'), + writeFile(join(root, 'src', 'cli.ts'), 'export const main = async () => 0;\n'), + writeFile(join(root, 'src', 'index.ts'), 'export const answer = 42;\n'), + writeFile( + join(root, 'src', 'mcp', 'curator.ts'), + 'export default () => ({ close() {}, async connect() {} });\n', + ), + writeFile(join(root, 'src', 'view.tsx'), 'export default () => null;\n'), + ]); + return root; +}; + +const bundlerEntries = (result: ReadyInspectResult): readonly BundlerInspectionEntry[] => { + const bundler = result.selected?.bundler; + if (bundler === undefined) throw new Error('Bundler inspection was not selected.'); + return bundler.entries; +}; + +const entryOf = ( + entries: readonly BundlerInspectionEntry[], + kind: BundlerInspectionEntry['kind'], + name: string, +): BundlerInspectionEntry => { + const entry = entries.find((candidate) => candidate.kind === kind && candidate.name === name); + if (entry === undefined) throw new Error(`Missing bundler inspection entry ${kind}:${name}.`); + return entry; +}; + +it('surfaces every synthesized bundler config with the tools hatch merged over the profile', async () => { + const root = await createProject(); + const result = await inspect({ focus: 'bundler', root }); + expect(result.state).toBe('ready'); + const ready = result as ReadyInspectResult; + const entries = bundlerEntries(ready); + + 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-entry:curator', + 'portable:script:tool', + ]); + + const script = entryOf(entries, 'script', 'tool'); + expect(script).toMatchObject({ + bundler: 'rslib', + outputPath: 'portable/scripts/tool.mjs', + source: `${root}/src/tool.ts`, + target: 'portable', + }); + // The generated executable envelope wraps the `main` export. + expect(script.generatedEntry).toContain('process.argv.slice(2)'); + expect(script.config).toMatchObject({ + id: 'agent-bundle-tool', + output: { + distPath: { root: '/portable' }, + filename: { js: 'scripts/tool.mjs' }, + // The consumer rsbuild hatch merges over the framework profile value. + legalComments: 'linked', + target: 'node', + }, + syntax: 'es2022', + tools: { + // The consumer rspack hatch is merged before the framework invariant + // hook, which always runs last. + rspack: [ + { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }, + '[function enforceInvariants]', + ], + }, + }); + + 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(/^portable\/mcp\/mcp-curator-[a-f\d]{8}\.mjs$/u); + + 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 lib = entryOf(entries, 'lib', 'index'); + expect(lib).toMatchObject({ + config: { + dts: true, + source: { tsconfigPath: '' }, + }, + outputPath: 'dist/index.js', + }); + + const apps = entryOf(entries, 'mcp-apps', 'mcp-apps'); + expect(apps.bundler).toBe('rsbuild'); + expect(apps.config).toMatchObject({ + environments: { + dashboard: { source: { entry: { dashboard: `${root}/src/view.tsx` } } }, + }, + output: { + distPath: { html: 'mcp-apps', root: '/portable' }, + inlineScripts: true, + // The consumer rsbuild hatch also merges over the view profile. + legalComments: 'linked', + }, + tools: { + rspack: [ + { resolve: { extensionAlias: { '.js': ['.js', '.ts'] } } }, + '[function enforceInvariants]', + ], + }, + }); +}); + +it('keeps the bundler inspection deterministic and JSON-serializable', 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)); +}); + +it('keeps the bundler focus out of unfocused inspections', async () => { + const root = await createProject(); + const result = await inspect({ root }); + expect(result.state).toBe('ready'); + expect((result as ReadyInspectResult).selected).toBeUndefined(); +}); diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index bb28cb4e7..cd27d019d 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -479,9 +479,15 @@ it('rejects non-JSON MCP App metadata before normalization', async () => { plugin: { name: 'mcp-app-meta', version: '1.0.0' }, } as unknown as AgentBundleConfig; - expect(validateSource(loadedProject(root, config), { skills: [] }, registry).map(({ code }) => code)).toEqual([ + const diagnostics = validateSource(loadedProject(root, config), { skills: [] }, registry); + expect(diagnostics.filter(({ severity }) => severity === 'error').map(({ code }) => code)).toEqual([ 'AB4338', ]); + // The self-connecting fixture entry additionally draws the AB4730 + // migration nudge, which must stay informational. + expect(diagnostics.filter(({ severity }) => severity !== 'error')).toEqual([ + expect.objectContaining({ code: 'AB4730', severity: 'info' }), + ]); } } finally { await rm(root, { force: true, recursive: true }); @@ -828,7 +834,12 @@ it('compiles one shared MCP App once and serves it from every identically declar plugin: { name: 'mcp-app-shared', version: '1.0.0' }, targets: ['portable'], }; - expect(validateSource(loadedProject(root, config), { skills: [] }, registry)).toEqual([]); + // Both fixture entries are deliberately self-connecting registry probes, + // so validation reports exactly the two informational AB4730 nudges. + expect(validateSource(loadedProject(root, config), { skills: [] }, registry)).toEqual([ + expect.objectContaining({ code: 'AB4730', severity: 'info' }), + expect.objectContaining({ code: 'AB4730', severity: 'info' }), + ]); const model = await normalizeProject(loadedProject(root, config), { skills: [] }, registry); const outputRoot = join(root, 'dist'); diff --git a/packages/agent-bundle/tests/package-conventions.test.ts b/packages/agent-bundle/tests/package-conventions.test.ts index 4078d7f03..05d08246f 100644 --- a/packages/agent-bundle/tests/package-conventions.test.ts +++ b/packages/agent-bundle/tests/package-conventions.test.ts @@ -263,3 +263,186 @@ describe('bin, lib, and tools validation', () => { expect(diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); }); }); + +describe('migration nudges (AB473x)', () => { + const factoryEntry = 'export default () => ({ close() {}, async connect() {} });\n'; + const selfConnectingEntry = [ + "import { connect } from './transport.js';", + 'await connect();', + '', + ].join('\n'); + + const validated = async ( + config: Omit, + files: Readonly> = {}, + ) => { + const root = await projectRoot(files); + return { + diagnostics: validateSource(loadedProject({ + ...config, + plugin: { name: 'review-tools', version: '1.0.0' }, + }, root), { skills: [] }, registry), + root, + }; + }; + + it('nudges AB4730 for a self-connecting explicit stdio entry', async () => { + const { diagnostics, root } = await validated( + { mcp: { servers: { curator: { entry: './src/server.ts' } } } }, + { 'src/server.ts': selfConnectingEntry }, + ); + expect(diagnostics).toEqual([{ + code: 'AB4730', + message: expect.stringContaining('self-connecting'), + recovery: expect.stringContaining('Optional'), + severity: 'info', + sourcePath: `${root}/src/server.ts`, + }]); + }); + + it('nudges AB4730 for a self-connecting conventional stdio entry', async () => { + const { diagnostics, root } = await validated( + { mcp: { servers: { curator: {} } } }, + { 'src/mcp/curator.ts': selfConnectingEntry }, + ); + expect(diagnostics).toEqual([expect.objectContaining({ + code: 'AB4730', + severity: 'info', + sourcePath: `${root}/src/mcp/curator.ts`, + })]); + }); + + it('stays silent for factory-exporting stdio entries', async () => { + const { diagnostics } = await validated( + { + mcp: { + servers: { + curator: {}, + explicit: { entry: './src/factory.ts' }, + }, + }, + }, + { + 'src/factory.ts': factoryEntry, + 'src/mcp/curator.ts': factoryEntry, + }, + ); + expect(diagnostics).toEqual([]); + }); + + it('nudges AB4731 when explicit bin configuration shadows src/cli.ts', async () => { + const { diagnostics, root } = await validated( + { bin: { other: './src/other.ts' } }, + { + 'src/cli.ts': 'export const main = async () => 0;\n', + 'src/other.ts': 'export const main = async () => 0;\n', + }, + ); + expect(diagnostics).toEqual([{ + code: 'AB4731', + message: expect.stringContaining('src/cli.ts'), + recovery: expect.stringContaining('Optional'), + severity: 'info', + sourcePath: `${root}/src/cli.ts`, + }]); + }); + + it('stays silent when bin config references src/cli.ts or opts out with false', async () => { + const files = { + 'src/cli.ts': 'export const main = async () => 0;\n', + 'src/other.ts': 'export const main = async () => 0;\n', + }; + const referencing = await validated( + { bin: { other: './src/other.ts', tool: './src/cli.ts' } }, + files, + ); + expect(referencing.diagnostics).toEqual([]); + + const optedOut = await validated({ bin: false }, files); + expect(optedOut.diagnostics).toEqual([]); + }); + + it('nudges AB4732 when explicit lib configuration shadows src/index.ts', async () => { + const { diagnostics, root } = await validated( + { lib: { entry: './src/other.ts' } }, + { + 'src/index.ts': 'export const a = 1;\n', + 'src/other.ts': 'export const b = 2;\n', + }, + ); + expect(diagnostics).toEqual([{ + code: 'AB4732', + message: expect.stringContaining('src/index.ts'), + recovery: expect.stringContaining('Optional'), + severity: 'info', + sourcePath: `${root}/src/index.ts`, + }]); + }); + + it('stays silent when lib config references src/index.ts or opts out with false', async () => { + const files = { + 'src/index.ts': 'export const a = 1;\n', + 'src/other.ts': 'export const b = 2;\n', + }; + const referencing = await validated({ lib: './src/index.ts' }, files); + expect(referencing.diagnostics).toEqual([]); + + const optedOut = await validated({ lib: false }, files); + expect(optedOut.diagnostics).toEqual([]); + }); + + it('nudges AB4733 when explicit server configuration shadows src/mcp/.ts', async () => { + const entryShadow = await validated( + { mcp: { servers: { curator: { entry: './src/factory.ts' } } } }, + { + 'src/factory.ts': factoryEntry, + 'src/mcp/curator.ts': factoryEntry, + }, + ); + expect(entryShadow.diagnostics).toEqual([{ + code: 'AB4733', + message: expect.stringContaining('src/mcp/curator.ts'), + recovery: expect.stringContaining('Optional'), + severity: 'info', + sourcePath: `${entryShadow.root}/src/mcp/curator.ts`, + }]); + + const commandShadow = await validated( + { mcp: { servers: { curator: { command: 'curator-server' } } } }, + { 'src/mcp/curator.ts': factoryEntry }, + ); + expect(commandShadow.diagnostics).toEqual([expect.objectContaining({ + code: 'AB4733', + severity: 'info', + })]); + }); + + it('stays silent when the explicit entry is the conventional file itself', async () => { + const { diagnostics } = await validated( + { mcp: { servers: { curator: { entry: './src/mcp/curator.ts' } } } }, + { 'src/mcp/curator.ts': factoryEntry }, + ); + expect(diagnostics).toEqual([]); + }); + + it('never raises nudges above info severity', async () => { + const { diagnostics } = await validated( + { + bin: { other: './src/other.ts' }, + lib: { entry: './src/other.ts' }, + mcp: { servers: { curator: { entry: './src/server.ts' } } }, + }, + { + 'src/cli.ts': 'export const main = async () => 0;\n', + 'src/index.ts': 'export const a = 1;\n', + 'src/mcp/curator.ts': factoryEntry, + 'src/other.ts': 'export const main = async () => 0;\n', + 'src/server.ts': selfConnectingEntry, + }, + ); + expect(diagnostics.map((diagnostic) => diagnostic.code).sort()).toEqual([ + 'AB4730', 'AB4731', 'AB4732', 'AB4733', + ]); + expect(diagnostics.every((diagnostic) => diagnostic.severity === 'info')).toBe(true); + }); +}); diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts index e8d27e275..55b290920 100644 --- a/packages/agent-bundle/tests/public-api.test.ts +++ b/packages/agent-bundle/tests/public-api.test.ts @@ -223,6 +223,14 @@ it('keeps bundled config extension types in emitted root declarations', async () join(consumerRoot, 'node_modules', '@rspack'), 'dir', ); + // The bundler-inspection surface types the composed Rslib lib config, so + // the declaration graph resolves @rslib/core exactly as installed + // consumers do (also a runtime dependency of the package). + await symlink( + join(agentBundleNodeModules, '@rslib'), + join(consumerRoot, 'node_modules', '@rslib'), + 'dir', + ); await writeFile(join(emittedPackageRoot, 'package.json'), JSON.stringify({ exports: { '.': { types: './dist/index.d.ts' } }, name: 'agent-bundle', diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 495e012d8..0a77b127c 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -19,6 +19,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/build.test.ts', 'packages/agent-bundle/tests/cli.test.ts', 'packages/agent-bundle/tests/dev-artifact-service.test.ts', + 'packages/agent-bundle/tests/dev-package-build.test.ts', 'packages/agent-bundle/tests/dev-workbench.test.ts', 'packages/agent-bundle/tests/eval-claude-harness.test.ts', 'packages/agent-bundle/tests/eval-cli.test.ts',