diff --git a/.changeset/route-graph-ir.md b/.changeset/route-graph-ir.md new file mode 100644 index 000000000..5727f46d5 --- /dev/null +++ b/.changeset/route-graph-ir.md @@ -0,0 +1,10 @@ +--- +"agent-bundle": minor +--- + +Compile the conventional route tree into an immutable route-graph IR (#93, PR-1) and expose it through `agent-bundle inspect --routes`. + +- Discovery covers `src/mcp//{tools,resources,prompts,apps}/*.{ts,tsx}` (direct children per MCP kind), `src/events//*.{ts,tsx}` (`event:/`), `src/providers/*.{ts,tsx}` (a separate provider collection), and nested `src/cli/**` / `src/scripts/**` identities. Project ignore rules, private `_`/`.` segments, and `*.d.ts` files are skipped. Modules referenced by explicit `scripts`/`hooks`/`bin`/`lib`/`mcp` configuration are claimed by that declaration and never become routes, so existing layouts (for example `scripts` entries under `src/scripts/`) stay route-free without a migration. +- The graph is deep-frozen, every route carries `config: {}` until the config extractor lands (PR-2), and the graph digest covers project-relative identity only, so equal trees hash equally on every machine. +- Collisions are hard errors, never silent choices: `AB4800` (routed MCP server vs existing entry claim), `AB4801` (`src/cli.ts` vs `src/cli/`), `AB4802` (duplicate route id), `AB4803` (unsafe identity segment), `AB4804` (invalid `routes` mode override). Explicit `routes.servers.` (`generated`/`custom`/`command`/`remote`) and `routes.cli` (`generated`/`conventional`) overrides resolve conflicts; without one, the conflicting surface keeps its discovered routes in `conflict` mode beside the error. +- `discoverProject` attaches the graph only when it is non-empty, `validate` surfaces its diagnostics, and `inspect({ focus: 'routes' })` / `agent-bundle inspect --routes` dump the compiled graph like the bundler focus. `CapabilityState`/`CapabilityEvidence` types ship with the IR; population follows with the host-component work (#100). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 319f9ffcf..b8fb36665 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -118,6 +118,25 @@ simply not been built yet is a validation **warning** that only | `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. | | `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. | +## Route graph (`AB4800`–`AB4804`) + +The route-graph compiler discovers conventional route modules +(`src/mcp//{tools,resources,prompts,apps}/*`, `src/events/*/*`, +`src/providers/*`, `src/cli/**`, `src/scripts/**`) into one immutable IR. +Discovery is not a packaging choice, so every collision is a hard **error** +and the compiler never silently picks a side. Modules that explicit +`scripts`, `hooks`, `bin`, `lib`, or `mcp` configuration references are +claimed by that declaration and never become routes — config always wins. +`agent-bundle inspect --routes` dumps the compiled graph. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB4800` | error | An MCP server has both discovered route modules under `src/mcp//` and an existing entry claim (the conventional `src/mcp/.ts` module, or a declared `entry`/`command`/`url`) without an explicit `routes.servers.` mode. | +| `AB4801` | error | The conventional `src/cli.ts` entry and `src/cli/` command route modules both exist without an explicit `routes.cli` mode. | +| `AB4802` | error | Two route modules derive the same route id (for example `.ts` and `.tsx` siblings with one stem). | +| `AB4803` | error | A route path derives an unsafe identity segment (each segment must match `^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$`). | +| `AB4804` | error | A `routes` mode override is not `generated`/`custom`/`command`/`remote` for a server, or `generated`/`conventional` for the CLI. | + ## Development package build (`AB7103`) `agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin diff --git a/docs/superpowers/specs/2026-08-25-capability-aware-workbench-design.md b/docs/superpowers/specs/2026-08-25-capability-aware-workbench-design.md index 0606bc042..a3c92bf99 100644 --- a/docs/superpowers/specs/2026-08-25-capability-aware-workbench-design.md +++ b/docs/superpowers/specs/2026-08-25-capability-aware-workbench-design.md @@ -3,6 +3,12 @@ Date: 2026-08-25 Status: proposed +> Status note (2026-08-31): the nine-page Workbench is in tree. The remaining +> manifest navigation, Agent Document stage, replay, and read-only discovery +> work is tracked in +> [#105](https://github.com/ScriptedAlchemy/agent-bundle/issues/105). This +> spec is not the live execution plan. + ## Context The Workbench currently renders the same navigation and workflow for every diff --git a/docs/superpowers/specs/2026-08-26-rsc-plugin-app-and-audiobook-curator-parity-design.md b/docs/superpowers/specs/2026-08-26-rsc-plugin-app-and-audiobook-curator-parity-design.md index f91c107b5..e244d00c5 100644 --- a/docs/superpowers/specs/2026-08-26-rsc-plugin-app-and-audiobook-curator-parity-design.md +++ b/docs/superpowers/specs/2026-08-26-rsc-plugin-app-and-audiobook-curator-parity-design.md @@ -212,3 +212,4 @@ Real-world acceptance includes: - Shipping media, cached Audible payloads, credentials, Whisper models, or Python environments. - Adding hooks without a concrete curator lifecycle need. - Building a filesystem router or general web framework when a declarative application tree and shared operation registry suffice. + - Superseded (2026-08-31) by [#93](https://github.com/ScriptedAlchemy/agent-bundle/issues/93): the filesystem-router non-goal is withdrawn. The route compiler is planned. Public authoring waits for the wave-3 renderer join ([#107](https://github.com/ScriptedAlchemy/agent-bundle/issues/107)). The rest of these non-goals are unchanged. diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 8057c2bf0..47674dff1 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -6,7 +6,26 @@ import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts'; import { build as buildArtifact, type BuildResult } from './build/build.ts'; import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts'; import { isInsideOrEqual } from './core/paths.ts'; +import { emptyCompiledRouteGraph } from './routes/graph.ts'; +import { inspectRouteGraph, type RouteGraphInspection } from './routes/inspect.ts'; import { mcpServerStateDirectory, runMcpForeground } from './services/mcp-run.ts'; +export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './routes/graph.ts'; +export { inspectRouteGraph } from './routes/inspect.ts'; +export type { RouteGraphInspection } from './routes/inspect.ts'; +export { emptyRouteConfig } from './routes/types.ts'; +export type { + CapabilityEvidence, + CapabilityState, + CompiledAgentRoute, + CompiledCliMode, + CompiledCliSurface, + CompiledProvider, + CompiledRouteGraph, + CompiledRouteKind, + CompiledServerMode, + CompiledServerSurface, + RouteProvenance, +} from './routes/types.ts'; export type { BuildResult } from './build/build.ts'; export type { PackageBuildResult, PackageOutputFile } from './build/package-build.ts'; export type { ArtifactOutputKind, ArtifactOutputProvenance } from './build/provenance.ts'; @@ -199,7 +218,7 @@ export interface InspectionPlan { } export interface InspectOptions extends ProjectOptions { - readonly focus?: 'bundler' | 'hooks' | 'skills'; + readonly focus?: 'bundler' | 'hooks' | 'routes' | 'skills'; readonly target?: string; } @@ -211,6 +230,7 @@ export interface ReadyInspectResult { readonly selected?: { readonly bundler?: BundlerInspection; readonly hooks?: NormalizedPlugin['hooks']; + readonly routes?: RouteGraphInspection; readonly skills?: NormalizedPlugin['skills']; }; readonly state: 'ready'; @@ -472,11 +492,19 @@ export const inspect = async (options: InspectOptions): Promise = ])); } } + // The route focus serves the graph preparation already compiled during + // discovery — never a second configuration evaluation, so the focus can + // not diverge from the validated model. Route-free projects attach no + // graph and serve the shared empty one. + const routes: RouteGraphInspection | undefined = options.focus === 'routes' + ? inspectRouteGraph(prepared.routeGraph ?? emptyCompiledRouteGraph) + : undefined; const selected = options.focus === undefined ? undefined : Object.freeze({ ...(bundler === undefined ? {} : { bundler }), ...(options.focus === 'hooks' ? { hooks: model.hooks } : {}), + ...(routes === undefined ? {} : { routes }), ...(options.focus === 'skills' ? { skills: model.skills } : {}), }); return Object.freeze({ diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 91e60fc6a..955b16c80 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -72,6 +72,7 @@ interface InspectCommandOptions { readonly json?: boolean; readonly mode?: string; readonly root: string; + readonly routes?: boolean; readonly skills?: boolean; readonly target?: string; } @@ -215,6 +216,12 @@ const writeHumanInspect = (output: Output, result: Awaited plan.target).join(', ')}\n`); // Release identity is derived from package.json (issue #94); a project // without a package version gets a clearly labeled development fallback. @@ -416,9 +423,10 @@ export const runCli = async ( ) .option('--bundler', 'Include the synthesized bundler configuration focus') .option('--hooks', 'Include the hook focus') + .option('--routes', 'Include the compiled route-graph focus') .option('--skills', 'Include the skill focus'); inspectCommand.action(async (options: InspectCommandOptions) => { - const focuses = [options.bundler, options.hooks, options.skills].filter((focus) => focus === true); + const focuses = [options.bundler, options.hooks, options.routes, options.skills].filter((focus) => focus === true); if (focuses.length > 1) { throw new TypeError('Choose at most one inspect focus.'); } @@ -427,6 +435,7 @@ export const runCli = async ( ...inspectProjectOptions(options), ...(options.bundler === true ? { focus: 'bundler' as const } : {}), ...(options.hooks === true ? { focus: 'hooks' as const } : {}), + ...(options.routes === true ? { focus: 'routes' as const } : {}), ...(options.skills === true ? { focus: 'skills' as const } : {}), ...(options.target === undefined ? {} : { target: options.target }), }); diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 26d4f6395..9923da6e2 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -6,6 +6,8 @@ import fastGlob from 'fast-glob'; import { isInside } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleConfig } from '../core/types.ts'; +import { compileRouteGraph, isEmptyRouteGraph } from '../routes/graph.ts'; +import type { CompiledRouteGraph } from '../routes/types.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts'; import { isRenderedSkillSourceName } from './rendered-skill.ts'; import { parseSkill, type SkillDocument } from './skill.ts'; @@ -38,6 +40,12 @@ export interface DiscoveredPayload { export interface DiscoveredProject { assets?: DiscoveredAsset[]; payloads?: DiscoveredPayload[]; + /** + * The compiled conventional route graph (#93). Present only when route + * discovery found modules or produced diagnostics, so route-free projects + * keep their existing discovery shape. + */ + routeGraph?: CompiledRouteGraph; /** * Conventional `skills//SKILL.md` documents that explicit `skills` * configuration leaves uncovered — the confusable shadowed state surfaced @@ -229,9 +237,11 @@ export const discoverProject = async ( const shadowedConventionalSkills = [...shadowedByDir.values()]; const payloads = await discoverPayloads(projectRoot, config.payload); + const routeGraph = await compileRouteGraph(projectRoot, config, rules); return { assets: await discoverAssets(projectRoot, config.assets, rules), ...(payloads.length === 0 ? {} : { payloads }), + ...(routeGraph === undefined || isEmptyRouteGraph(routeGraph) ? {} : { routeGraph }), ...(shadowedConventionalSkills.length === 0 ? {} : { shadowedConventionalSkills }), skills: await Promise.all( skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules)), diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 0b2da5b34..5c12d9d9f 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1459,6 +1459,12 @@ export const validateSource = ( diagnostics.push(...validateTools(loaded)); diagnostics.push(...packageConventionShadowNudges(loaded)); diagnostics.push(...skillConventionShadowNudges(loaded, discovered)); + // Route overrides are validated during discovery; source validation must + // still observe the config getter so hostile accessors fail closed as AB7001. + void loaded.config['routes']; + // Route-graph collisions (AB4800-AB4804) are compiled during discovery; + // they are project-source errors, so they gate inspect and build here. + diagnostics.push(...(discovered.routeGraph?.diagnostics ?? [])); return diagnostics; }; diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index a2c8cd172..ab6ad6452 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -28,6 +28,7 @@ import type { NormalizedMcpServer, NormalizedPlugin, } from '../core/types.ts'; +import type { CompiledRouteGraph } from '../routes/types.ts'; import type { DevRuntimePreparedMcpApp, DevRuntimePreparedMcpServer, DevRuntimePreparedProject } from './runtime-provider.ts'; import { freezeJsonValue, type JsonObject, type JsonValue, type SourceStatus } from './types.ts'; @@ -60,6 +61,12 @@ export interface PreparedProject { readonly projectContext?: ProjectContext; readonly registry: TargetRegistry; readonly root: string; + /** + * The compiled route graph discovery attached (#93); absent when no + * conventional route module exists. Carried through preparation so inspect + * never re-evaluates the configuration for the routes focus. + */ + readonly routeGraph?: CompiledRouteGraph; /** * Re-snapshots the project with the same output and payload roots the * prepared identity hashed; a divergent re-snapshot would make @@ -548,6 +555,7 @@ const preparedProject = ( devRuntimeDiagnostic?: Diagnostic, devAgentApiEnabled?: boolean, tools?: AgentBundleToolsConfig, + routeGraph?: CompiledRouteGraph, ): PreparedProject => Object.freeze({ configPath, ...(devAgentApiEnabled === true ? { devAgentApiEnabled } : {}), @@ -559,6 +567,7 @@ const preparedProject = ( ...(projectContext === undefined ? {} : { projectContext }), registry, root, + ...(routeGraph === undefined ? {} : { routeGraph }), snapshotSource, source, ...(tools === undefined ? {} : { tools }), @@ -753,7 +762,23 @@ export class ProjectService { if (hasErrors(sourceDiagnostics)) { const source = sourceStatus(sourceDiagnostics, snapshot.revision, root); log(this.#options.logger, 'project.invalid-source', { diagnostics: sourceDiagnostics.length, root }); - return preparedProject(loaded.configPath, snapshot, sourceDiagnostics, outputRoots, undefined, registry, root, source, snapshotSource); + return preparedProject( + loaded.configPath, + snapshot, + sourceDiagnostics, + outputRoots, + undefined, + registry, + root, + source, + snapshotSource, + undefined, + undefined, + undefined, + undefined, + undefined, + discovered.routeGraph, + ); } let model: NormalizedPlugin; @@ -868,6 +893,7 @@ export class ProjectService { devRuntimeDiagnostic, devAgentApiEnabled, tools, + discovered.routeGraph, ); } } diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts new file mode 100644 index 000000000..877c2a73d --- /dev/null +++ b/packages/agent-bundle/src/routes/graph.ts @@ -0,0 +1,505 @@ +import { existsSync, statSync } from 'node:fs'; +import { extname, relative, resolve } from 'node:path'; + +import fastGlob from 'fast-glob'; + +import { isProjectPathIgnored, readProjectIgnoreRules, toPosixPath } from '../config/ignore.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { digest } from '../core/digest.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import { isRecord } from '../core/strict-json.ts'; +import type { AgentBundleConfig } from '../core/types.ts'; +import { + emptyRouteConfig, + type CompiledAgentRoute, + type CompiledCliMode, + type CompiledCliSurface, + type CompiledProvider, + type CompiledRouteGraph, + type CompiledRouteKind, + type CompiledServerMode, + type CompiledServerSurface, +} from './types.ts'; + +type ProjectIgnoreRules = Awaited>; + +/** + * The conventional route roots. MCP route kinds are direct children of their + * kind directory; CLI and script routes nest freely (nesting is identity); + * events pair one family directory with one route file; providers are one + * flat collection. + */ +const routeGlobs = [ + 'src/mcp/*/{tools,resources,prompts,apps}/*.{ts,tsx}', + 'src/events/*/*.{ts,tsx}', + 'src/providers/*.{ts,tsx}', + 'src/cli/**/*.{ts,tsx}', + 'src/scripts/**/*.{ts,tsx}', +]; + +const mcpRouteKinds: Readonly> = { + apps: 'app', + prompts: 'prompt', + resources: 'resource', + tools: 'tool', +}; + +/** Every identity segment a route path contributes must be a safe name. */ +const safeIdentitySegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; + +const serverModeOverrides = new Set(['generated', 'custom', 'command', 'remote']); + +const conventionalEntryExtensions = ['.ts', '.tsx'] as const; + +/** + * Local copy of the conventional-entry probe from config/normalize.ts. + * Importing it would close the cycle discover.ts -> routes/graph.ts -> + * normalize.ts -> discover.ts, so the probe is duplicated here with the + * same .ts/.tsx rule. + */ +const conventionalEntryAt = (root: string, ...segments: string[]): string | undefined => { + const stem = resolve(root, ...segments); + for (const extension of conventionalEntryExtensions) { + const candidate = `${stem}${extension}`; + try { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } catch { + // A racing deletion means the convention does not apply. + } + } + return undefined; +}; + +const routeError = (code: string, message: string, recovery: string, sourcePath?: string): Diagnostic => ({ + code, + message, + recovery, + severity: 'error', + ...(sourcePath === undefined ? {} : { sourcePath }), +}); + +const configValue = ( + config: Readonly, + key: keyof AgentBundleConfig | 'routes', +): unknown => { + try { + return Reflect.get(config, key); + } catch { + return undefined; + } +}; + +interface DiscoveredProviderModule { + readonly id: string; + /** The path-derived name segments; each must satisfy the safe-identity rule. */ + readonly identitySegments: readonly string[]; + readonly name: string; + readonly relativePath: string; + readonly source: string; + readonly surface: 'provider'; +} + +interface DiscoveredRouteModule { + readonly id: string; + /** The path-derived name segments; each must satisfy the safe-identity rule. */ + readonly identitySegments: readonly string[]; + readonly kind: CompiledRouteKind; + readonly relativePath: string; + readonly serverName?: string; + readonly source: string; + readonly surface: 'route'; +} + +type DiscoveredModule = DiscoveredProviderModule | DiscoveredRouteModule; + +const stemOf = (fileName: string): string => fileName.slice(0, -extname(fileName).length); + +/** Derives kind and identity from one glob-matched route path; the globs guarantee segment shape. */ +const classifyModule = (source: string, relativePath: string): DiscoveredModule => { + const segments = relativePath.split('/'); + const collection = segments[1]!; + const stem = stemOf(segments[segments.length - 1]!); + if (collection === 'mcp') { + const serverName = segments[2]!; + const kind = mcpRouteKinds[segments[3]!]!; + return { + id: `${kind}:${serverName}/${stem}`, + identitySegments: [serverName, stem], + kind, + relativePath, + serverName, + source, + surface: 'route', + }; + } + if (collection === 'events') { + const family = segments[2]!; + return { + id: `event:${family}/${stem}`, + identitySegments: [family, stem], + kind: 'event-route', + relativePath, + source, + surface: 'route', + }; + } + if (collection === 'providers') { + return { + id: `provider:${stem}`, + identitySegments: [stem], + name: stem, + relativePath, + source, + surface: 'provider', + }; + } + const nested = [...segments.slice(2, -1), stem]; + const kind: CompiledRouteKind = collection === 'cli' ? 'cli' : 'script'; + return { + id: `${kind}:${nested.join('/')}`, + identitySegments: nested, + kind, + relativePath, + source, + surface: 'route', + }; +}; + +/** Any private (`_`/`.`) segment or declaration file opts the module out of discovery. */ +const isPrivateRoutePath = (relativePath: string): boolean => + relativePath.endsWith('.d.ts') || + relativePath.split('/').some((segment) => segment.startsWith('_') || segment.startsWith('.')); + +const claimedModuleEntry = (value: unknown): string | undefined => { + if (typeof value === 'string') return value; + if (isRecord(value) && typeof value.entry === 'string') return value.entry; + return undefined; +}; + +/** + * Absolute module paths explicit configuration already claims. Config always + * wins — the rule the entry conventions established — so a module an explicit + * `scripts`, `hooks`, `bin`, `lib`, or `mcp` declaration references belongs + * to that declaration and never becomes a conventional route. Two shipped + * examples declare `scripts` entries under `src/scripts/`; this rule keeps + * their layouts route-free without a migration. + */ +export const configClaimedSources = ( + projectRoot: string, + config: Readonly, +): ReadonlySet => { + const claimed = new Set(); + const claim = (value: unknown): void => { + const entry = claimedModuleEntry(value); + if (entry !== undefined && entry.trim().length > 0) claimed.add(resolve(projectRoot, entry)); + }; + const scripts = configValue(config, 'scripts'); + if (isRecord(scripts)) { + for (const value of Object.values(scripts)) claim(value); + } + const hooks = configValue(config, 'hooks'); + if (isRecord(hooks)) { + for (const input of Object.values(hooks)) { + for (const rawEntry of Array.isArray(input) ? input : [input]) { + claim(typeof rawEntry === 'string' ? rawEntry : isRecord(rawEntry) ? rawEntry.handler : undefined); + } + } + } + const bin = configValue(config, 'bin'); + if (isRecord(bin)) { + for (const value of Object.values(bin)) claim(value); + } + claim(configValue(config, 'lib')); + const mcp = configValue(config, 'mcp'); + const servers = isRecord(mcp) && isRecord(mcp.servers) ? mcp.servers : undefined; + for (const server of Object.values(servers ?? {})) { + if (!isRecord(server)) continue; + claim(server.entry); + if (!isRecord(server.apps)) continue; + for (const app of Object.values(server.apps)) { + if (!isRecord(app)) continue; + claim(app.entry); + claim(app.template); + } + } + return claimed; +}; + +interface RouteModeOverrides { + readonly cli?: 'generated' | 'conventional'; + readonly servers: ReadonlyMap; +} + +/** + * Parses the power-tier `routes` override block. It rides the config index + * signature deliberately: the route compiler is not yet a public authoring + * surface, so the typed config stays unchanged until the renderer join. + */ +const parseRouteModeOverrides = ( + config: Readonly, + diagnostics: Diagnostic[], +): RouteModeOverrides => { + const overrideRecovery = 'Set routes.servers. to generated, custom, command, or remote, and routes.cli to generated or conventional.'; + const declared = configValue(config, 'routes'); + const servers = new Map(); + let cli: 'generated' | 'conventional' | undefined; + if (declared === undefined) return { servers }; + if (!isRecord(declared)) { + diagnostics.push(routeError('AB4804', 'Routes overrides must be an object.', overrideRecovery)); + return { servers }; + } + const declaredServers = declared.servers; + if (declaredServers !== undefined) { + if (!isRecord(declaredServers)) { + diagnostics.push(routeError('AB4804', 'Routes server overrides must be an object of server modes.', overrideRecovery)); + } else { + for (const [name, mode] of Object.entries(declaredServers)) { + if (typeof mode === 'string' && serverModeOverrides.has(mode)) { + servers.set(name, mode as CompiledServerMode); + } else { + diagnostics.push(routeError( + 'AB4804', + `Routes override for MCP server ${JSON.stringify(name)} must be generated, custom, command, or remote; got ${JSON.stringify(mode)}.`, + overrideRecovery, + )); + } + } + } + } + const declaredCli = declared.cli; + if (declaredCli !== undefined) { + if (declaredCli === 'generated' || declaredCli === 'conventional') { + cli = declaredCli; + } else { + diagnostics.push(routeError( + 'AB4804', + `Routes CLI override must be generated or conventional; got ${JSON.stringify(declaredCli)}.`, + overrideRecovery, + )); + } + } + return { ...(cli === undefined ? {} : { cli }), servers }; +}; + +/** The declared config entry for one MCP server, tolerant of malformed config shapes. */ +const declaredMcpServer = ( + config: Readonly, + name: string, +): Readonly> | undefined => { + const mcp = configValue(config, 'mcp'); + if (!isRecord(mcp) || !isRecord(mcp.servers)) return undefined; + const server = (mcp.servers as Readonly>)[name]; + return isRecord(server) ? server : undefined; +}; + +const compiledRoute = (module: DiscoveredRouteModule): CompiledAgentRoute => ({ + config: emptyRouteConfig, + id: module.id, + kind: module.kind, + provenance: { kind: 'conventional', relativePath: module.relativePath }, + ...(module.serverName === undefined ? {} : { serverId: `mcp:${module.serverName}` }), + source: module.source, +}); + +const routeIdentity = (route: CompiledAgentRoute): Readonly> => ({ + id: route.id, + kind: route.kind, + relativePath: route.provenance.relativePath, + ...(route.serverId === undefined ? {} : { serverId: route.serverId }), +}); + +/** + * The graph of a route-free project: what {@link compileRouteGraph} returns + * when no conventional route module exists and no diagnostic fires. The + * inspect focus serves this constant when discovery attached no graph. + */ +export const emptyCompiledRouteGraph: CompiledRouteGraph = deepFreeze({ + diagnostics: [], + digest: digest({ events: [], providers: [], scripts: [], servers: [] }), + events: [], + providers: [], + scripts: [], + servers: [], +}); + +/** True when discovery found nothing and produced no diagnostics, so callers can omit the graph. */ +export const isEmptyRouteGraph = (graph: CompiledRouteGraph): boolean => + graph.cli === undefined && + graph.diagnostics.length === 0 && + graph.events.length === 0 && + graph.providers.length === 0 && + graph.scripts.length === 0 && + graph.servers.length === 0; + +/** + * Compiles the conventional route tree into the immutable route graph IR. + * Discovery is not a packaging choice: conflicts with existing entry + * conventions or declared servers keep their discovered routes visible and + * surface hard errors instead of silently choosing a side. + */ +export const compileRouteGraph = async ( + root: string, + config: Readonly, + ignoreRules?: ProjectIgnoreRules, +): Promise => { + const projectRoot = resolve(root); + const rules = ignoreRules ?? await readProjectIgnoreRules(projectRoot); + const diagnostics: Diagnostic[] = []; + const overrides = parseRouteModeOverrides(config, diagnostics); + + const sources = (await fastGlob(routeGlobs, { + absolute: true, + cwd: projectRoot, + dot: true, + followSymbolicLinks: false, + onlyFiles: true, + })).sort((left, right) => left.localeCompare(right)); + + const claimed = configClaimedSources(projectRoot, config); + const modules: DiscoveredModule[] = []; + const modulesById = new Map(); + for (const source of sources) { + if (claimed.has(source)) continue; + const relativePath = toPosixPath(relative(projectRoot, source)); + if (isPrivateRoutePath(relativePath) || isProjectPathIgnored(rules, projectRoot, source)) continue; + const module = classifyModule(source, relativePath); + const unsafeSegment = module.identitySegments.find((segment) => !safeIdentitySegment.test(segment)); + if (unsafeSegment !== undefined) { + diagnostics.push(routeError( + 'AB4803', + `Route module ${relativePath} derives the unsafe identity segment ${JSON.stringify(unsafeSegment)}; use letters, digits, and inner ".", "_", "-" only.`, + 'Rename the route file or directory to a safe identity segment, then inspect again.', + source, + )); + continue; + } + const existing = modulesById.get(module.id); + if (existing !== undefined) { + diagnostics.push(routeError( + 'AB4802', + `Route id ${JSON.stringify(module.id)} is declared by both ${existing.relativePath} and ${relativePath}.`, + 'Keep exactly one route module per identity, then inspect again.', + source, + )); + continue; + } + modulesById.set(module.id, module); + modules.push(module); + } + + const serverRoutes = new Map(); + const events: CompiledAgentRoute[] = []; + const scripts: CompiledAgentRoute[] = []; + const cliRoutes: CompiledAgentRoute[] = []; + const providers: CompiledProvider[] = []; + for (const module of modules) { + if (module.surface === 'provider') { + providers.push({ + id: module.id, + name: module.name, + provenance: { kind: 'conventional', relativePath: module.relativePath }, + source: module.source, + }); + continue; + } + const route = compiledRoute(module); + switch (route.kind) { + case 'tool': + case 'resource': + case 'prompt': + case 'app': { + const routes = serverRoutes.get(module.serverName!) ?? []; + routes.push(route); + serverRoutes.set(module.serverName!, routes); + break; + } + case 'event-route': + events.push(route); + break; + case 'cli': + cliRoutes.push(route); + break; + case 'script': + scripts.push(route); + break; + default: { + const unreachable: never = route.kind; + throw new TypeError(`Unhandled route kind ${String(unreachable)}.`); + } + } + } + + const servers: CompiledServerSurface[] = []; + for (const [name, routes] of [...serverRoutes.entries()].sort(([left], [right]) => left.localeCompare(right))) { + const override = overrides.servers.get(name); + const declared = declaredMcpServer(config, name); + const declaredEntry = declared !== undefined && + (declared.entry !== undefined || declared.command !== undefined || declared.url !== undefined); + const conventionalEntry = conventionalEntryAt(projectRoot, 'src', 'mcp', name); + let mode: CompiledServerMode; + if (override === 'custom' || override === 'command' || override === 'remote') { + mode = override; + } else if (override === 'generated' || (conventionalEntry === undefined && !declaredEntry)) { + mode = 'generated'; + } else { + mode = 'conflict'; + const claim = conventionalEntry === undefined + ? 'an explicit entry, command, or url in config' + : `the conventional src/mcp/${name} entry module`; + diagnostics.push(routeError( + 'AB4800', + `MCP server ${JSON.stringify(name)} has both ${claim} and src/mcp/${name}/ route modules; the compiler never chooses silently.`, + `Set routes.servers.${name} to generated to compile the routes, or to custom, command, or remote to keep the existing entry.`, + conventionalEntry ?? routes[0]!.source, + )); + } + servers.push({ + id: `mcp:${name}`, + mode, + name, + routes: mode === 'custom' || mode === 'command' || mode === 'remote' ? [] : routes, + }); + } + + let cli: CompiledCliSurface | undefined; + if (cliRoutes.length > 0) { + const conventionalCli = conventionalEntryAt(projectRoot, 'src', 'cli'); + let mode: CompiledCliMode; + if (overrides.cli !== undefined) { + mode = overrides.cli; + } else if (conventionalCli === undefined) { + mode = 'generated'; + } else { + mode = 'conflict'; + diagnostics.push(routeError( + 'AB4801', + 'The conventional src/cli entry module and src/cli/ command route modules both exist; the compiler never chooses silently.', + 'Set routes.cli to generated to compile the command routes, or to conventional to keep the src/cli entry.', + conventionalCli, + )); + } + cli = { mode, routes: mode === 'conventional' ? [] : cliRoutes }; + } + + const identity = { + ...(cli === undefined ? {} : { cli: { mode: cli.mode, routes: cli.routes.map(routeIdentity) } }), + events: events.map(routeIdentity), + providers: providers.map((provider) => ({ id: provider.id, relativePath: provider.provenance.relativePath })), + scripts: scripts.map(routeIdentity), + servers: servers.map((server) => ({ + id: server.id, + mode: server.mode, + routes: server.routes.map(routeIdentity), + })), + }; + + return deepFreeze({ + ...(cli === undefined ? {} : { cli }), + diagnostics, + digest: digest(identity), + events, + providers, + scripts, + servers, + }); +}; diff --git a/packages/agent-bundle/src/routes/index.ts b/packages/agent-bundle/src/routes/index.ts new file mode 100644 index 000000000..df89df87f --- /dev/null +++ b/packages/agent-bundle/src/routes/index.ts @@ -0,0 +1,17 @@ +export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './graph.ts'; +export { inspectRouteGraph } from './inspect.ts'; +export type { RouteGraphInspection } from './inspect.ts'; +export { emptyRouteConfig } from './types.ts'; +export type { + CapabilityEvidence, + CapabilityState, + CompiledAgentRoute, + CompiledCliMode, + CompiledCliSurface, + CompiledProvider, + CompiledRouteGraph, + CompiledRouteKind, + CompiledServerMode, + CompiledServerSurface, + RouteProvenance, +} from './types.ts'; diff --git a/packages/agent-bundle/src/routes/inspect.ts b/packages/agent-bundle/src/routes/inspect.ts new file mode 100644 index 000000000..4795dac62 --- /dev/null +++ b/packages/agent-bundle/src/routes/inspect.ts @@ -0,0 +1,11 @@ +import type { CompiledRouteGraph } from './types.ts'; + +/** + * The route focus of `agent-bundle inspect` dumps the compiler IR itself: + * like the bundler focus, the full graph is the debugging document, so the + * inspection type is the graph type. The alias keeps the inspection contract + * stable if a later release decorates the dump. + */ +export type RouteGraphInspection = CompiledRouteGraph; + +export const inspectRouteGraph = (graph: CompiledRouteGraph): RouteGraphInspection => graph; diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts new file mode 100644 index 000000000..051cc5691 --- /dev/null +++ b/packages/agent-bundle/src/routes/types.ts @@ -0,0 +1,126 @@ +import type { Diagnostic } from '../core/diagnostics.ts'; + +/** + * Every route kind the conventional source tree can declare. Context + * providers are deliberately not a route kind: a provider wraps route + * execution rather than being addressable itself, so providers live in their + * own {@link CompiledProvider} collection. + */ +export type CompiledRouteKind = + | 'tool' + | 'resource' + | 'prompt' + | 'app' + | 'event-route' + | 'cli' + | 'script'; + +/** + * Where a compiled route came from. This release only compiles conventional + * filesystem routes; the relative path is the route's portable identity, so + * graphs digest identically regardless of where the project is checked out. + */ +export interface RouteProvenance { + readonly kind: 'conventional'; + /** Project-relative POSIX path of the route module. */ + readonly relativePath: string; +} + +/** + * Evidence backing a `supported`/`degraded` capability judgment: the adapter + * capability record that asserted it, in the same identity terms the target + * adapter metadata already publishes. + */ +export interface CapabilityEvidence { + readonly capabilityRevision: string; + readonly capabilitySha256: string; + readonly observedVersion: string; + readonly target: string; +} + +/** + * The one capability-state contract route validation, projectors, events, + * and host components share (#93). This release exports the types only; + * population arrives with the host-component capability work (#100). + */ +export type CapabilityState = + | { readonly state: 'supported'; readonly evidence: CapabilityEvidence } + | { readonly state: 'degraded'; readonly reason: string; readonly evidence?: CapabilityEvidence } + | { readonly state: 'unavailable'; readonly reason: string } + | { readonly state: 'prohibited'; readonly reason: string }; + +/** + * The route `config` export is extracted by a later release; until then every + * compiled route carries this shared frozen empty object. + */ +export const emptyRouteConfig: Readonly> = Object.freeze({}); + +/** One conventional route module compiled into the immutable route graph. */ +export interface CompiledAgentRoute { + /** Static route metadata. Always {@link emptyRouteConfig} in this release. */ + readonly config: Readonly>; + readonly id: string; + readonly kind: CompiledRouteKind; + readonly provenance: RouteProvenance; + /** The owning MCP server id (`mcp:`); MCP route kinds only. */ + readonly serverId?: string; + /** Absolute route module path. */ + readonly source: string; +} + +/** One conventional `src/providers/.ts` context provider module. */ +export interface CompiledProvider { + readonly id: string; + readonly name: string; + readonly provenance: RouteProvenance; + /** Absolute provider module path. */ + readonly source: string; +} + +/** + * The packaging mode of one MCP server that owns discovered route modules. + * `generated`, `custom`, `command`, and `remote` are explicit or inferred + * decisions; `conflict` records that discovery found routes but an existing + * entry claims the same server and no explicit mode resolved it — discovery + * is not a packaging choice, so the routes stay visible beside the error. + */ +export type CompiledServerMode = 'generated' | 'custom' | 'command' | 'remote' | 'conflict'; + +/** One MCP server surface assembled from `src/mcp//` route modules. */ +export interface CompiledServerSurface { + readonly id: string; + readonly mode: CompiledServerMode; + readonly name: string; + /** Discovered routes; empty when an explicit non-generated mode omits them. */ + readonly routes: readonly CompiledAgentRoute[]; +} + +/** + * The CLI surface mode: `generated` compiles `src/cli/**` command routes, + * `conventional` keeps the existing `src/cli.ts` entry and omits the routed + * commands, and `conflict` records both present without an explicit choice. + */ +export type CompiledCliMode = 'generated' | 'conventional' | 'conflict'; + +/** The CLI command surface assembled from `src/cli/**` route modules. */ +export interface CompiledCliSurface { + readonly mode: CompiledCliMode; + /** Discovered command routes; empty when `conventional` mode omits them. */ + readonly routes: readonly CompiledAgentRoute[]; +} + +/** + * The immutable route graph: one compiler IR for everything the conventional + * source tree declares. Deep-frozen at compile time; `digest` covers only + * project-relative identity so equal trees hash equally on every machine. + */ +export interface CompiledRouteGraph { + readonly cli?: CompiledCliSurface; + readonly diagnostics: readonly Diagnostic[]; + /** sha256 over the graph's project-relative identity. */ + readonly digest: string; + readonly events: readonly CompiledAgentRoute[]; + readonly providers: readonly CompiledProvider[]; + readonly scripts: readonly CompiledAgentRoute[]; + readonly servers: readonly CompiledServerSurface[]; +} diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 8798fa543..6a85f91cc 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -373,6 +373,43 @@ it('contains hostile source getters as reusable preparation diagnostics', async } }); +it('fails closed when routes getter throws during inspection', async () => { + const root = await createProject(); + try { + await mkdir(join(root, 'src', 'mcp', 'curator', 'tools'), { recursive: true }); + await Promise.all([ + writeFile(join(root, 'agent-bundle.config.ts'), [ + "const hostile = { toString() { throw new Error('hostile routes getter was stringified'); } };", + 'hostile.self = hostile;', + 'const config = { plugin: { name: \'hostile-routes\', version: \'1.0.0\' }, targets: [\'codex\'] };', + "Object.defineProperty(config, 'routes', { enumerable: true, get() { throw hostile; } });", + 'export default config;', + '', + ].join('\n')), + writeFile(join(root, 'src', 'mcp', 'curator', 'tools', 'inspect.ts'), 'export default async () => undefined;\n'), + ]); + + const result = await inspect({ root }); + + expect(result).toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: 'Unable to validate project source.', + recovery: expect.any(String), + })], + plans: [], + state: 'invalid', + }); + expect(JSON.stringify(result)).not.toContain('hostile routes getter was stringified'); + expect('model' in result).toBe(false); + expect('projectContext' in result).toBe(false); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.diagnostics)).toBe(true); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}); + const hostileAdapterError = (): object => { const error = Object.create(null) as Record; error.self = error; diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts index d02a80878..6f96e56bd 100644 --- a/packages/agent-bundle/tests/public-api.test.ts +++ b/packages/agent-bundle/tests/public-api.test.ts @@ -211,6 +211,14 @@ it('keeps bundled config extension types in emitted root declarations', async () join(consumerRoot, 'node_modules', 'ajv'), 'dir', ); + // The route-graph compiler types its project ignore rules, so the + // declaration graph resolves ignore exactly as installed consumers do + // (a runtime dependency of the package). + await symlink( + join(agentBundleNodeModules, 'ignore'), + join(consumerRoot, 'node_modules', 'ignore'), + 'dir', + ); // The tools escape hatch types the Rsbuild environment-config surface, so // the declaration graph resolves the bundler packages exactly as // installed consumers do. @rsbuild/core is a runtime dependency; the diff --git a/packages/agent-bundle/tests/route-graph.test.ts b/packages/agent-bundle/tests/route-graph.test.ts new file mode 100644 index 000000000..28d638540 --- /dev/null +++ b/packages/agent-bundle/tests/route-graph.test.ts @@ -0,0 +1,385 @@ +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { afterEach, expect, it } from '@rstest/core'; + +import { inspect, type ReadyInspectResult } from '../src/api.ts'; +import { runCli } from '../src/cli.ts'; +import { discoverProject } from '../src/config/discover.ts'; +import type { AgentBundleConfig } from '../src/core/types.ts'; +import { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from '../src/routes/graph.ts'; +import { emptyRouteConfig } from '../src/routes/types.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const createRoot = async (): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-route-graph-'))); + roots.push(root); + return root; +}; + +const moduleSource = 'export default async () => undefined;\n'; + +const writeTree = async (root: string, files: Readonly>): Promise => { + for (const [path, contents] of Object.entries(files)) { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, contents); + } +}; + +const fixtureConfig = (extra: Readonly> = {}): AgentBundleConfig => ({ + plugin: { name: 'routes-fixture', version: '1.0.0' }, + ...extra, +}); + +const conventionalTree: Readonly> = { + 'src/cli/doctor.tsx': moduleSource, + 'src/cli/library/audit.ts': moduleSource, + 'src/events/file/saved.tsx': moduleSource, + 'src/mcp/curator/apps/dashboard.tsx': moduleSource, + 'src/mcp/curator/prompts/curate.tsx': moduleSource, + 'src/mcp/curator/resources/catalog.ts': moduleSource, + 'src/mcp/curator/tools/inspect.tsx': moduleSource, + 'src/providers/git-worktree.ts': moduleSource, + 'src/scripts/rebuild-index.ts': moduleSource, +}; + +const codesOf = (diagnostics: readonly { readonly code: string }[]): string[] => + diagnostics.map((diagnostic) => diagnostic.code); + +const createInspectProject = async (files: Readonly>): Promise => { + const root = await createRoot(); + await writeTree(root, { + 'agent-bundle.config.ts': [ + 'export default {', + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable'],", + '};', + '', + ].join('\n'), + 'package.json': '{"type":"module"}\n', + ...files, + }); + return root; +}; + +it('compiles the conventional tree into one frozen graph with a machine-independent digest', async () => { + const root = await createRoot(); + await writeTree(root, conventionalTree); + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics).toEqual([]); + expect(graph.servers).toHaveLength(1); + const [curator] = graph.servers; + expect(curator).toMatchObject({ id: 'mcp:curator', mode: 'generated', name: 'curator' }); + expect(curator!.routes.map((route) => route.id)).toEqual([ + 'app:curator/dashboard', + 'prompt:curator/curate', + 'resource:curator/catalog', + 'tool:curator/inspect', + ]); + expect(curator!.routes.map((route) => route.kind)).toEqual(['app', 'prompt', 'resource', 'tool']); + expect(curator!.routes.every((route) => route.serverId === 'mcp:curator')).toBe(true); + expect(graph.events.map((route) => route.id)).toEqual(['event:file/saved']); + expect(graph.events[0]).toMatchObject({ + kind: 'event-route', + provenance: { kind: 'conventional', relativePath: 'src/events/file/saved.tsx' }, + source: join(root, 'src/events/file/saved.tsx'), + }); + expect(graph.cli).toMatchObject({ mode: 'generated' }); + expect(graph.cli!.routes.map((route) => route.id)).toEqual(['cli:doctor', 'cli:library/audit']); + expect(graph.scripts.map((route) => route.id)).toEqual(['script:rebuild-index']); + expect(graph.providers).toEqual([{ + id: 'provider:git-worktree', + name: 'git-worktree', + provenance: { kind: 'conventional', relativePath: 'src/providers/git-worktree.ts' }, + source: join(root, 'src/providers/git-worktree.ts'), + }]); + + // The IR is immutable and every route carries the shared frozen empty config. + expect(Object.isFrozen(graph)).toBe(true); + expect(Object.isFrozen(graph.servers)).toBe(true); + expect(Object.isFrozen(curator!.routes[0])).toBe(true); + expect(Object.isFrozen(graph.cli!.routes)).toBe(true); + expect(curator!.routes.every((route) => route.config === emptyRouteConfig)).toBe(true); + expect(graph.events[0]!.config).toEqual({}); + + // The digest covers relative identity only: the same tree in a different + // absolute root produces the same digest. + const otherRoot = await createRoot(); + await writeTree(otherRoot, conventionalTree); + const otherGraph = await compileRouteGraph(otherRoot, fixtureConfig()); + expect(otherGraph.digest).toBe(graph.digest); + expect(isEmptyRouteGraph(graph)).toBe(false); +}); + +it('skips ignored paths, private segments, and declaration files', async () => { + const root = await createRoot(); + await writeTree(root, { + '.gitignore': 'src/scripts/generated.ts\n', + 'src/events/.internal/probe.ts': moduleSource, + 'src/mcp/curator/tools/_draft.ts': moduleSource, + 'src/mcp/curator/tools/inspect.ts': moduleSource, + 'src/mcp/curator/tools/types.d.ts': 'export type Probe = string;\n', + 'src/scripts/generated.ts': moduleSource, + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(graph.diagnostics).toEqual([]); + expect(graph.servers[0]!.routes.map((route) => route.id)).toEqual(['tool:curator/inspect']); + expect(graph.events).toEqual([]); + expect(graph.scripts).toEqual([]); +}); + +it('never compiles a module explicit configuration claims: config always wins', async () => { + const root = await createRoot(); + await writeTree(root, { + // The examples/hooks-and-scripts shape: explicit scripts entries under src/scripts/. + 'src/hooks/session-start.ts': moduleSource, + 'src/scripts/detect-risk.ts': moduleSource, + 'src/scripts/rebuild-index.ts': moduleSource, + 'src/scripts/verify-release.ts': moduleSource, + }); + const graph = await compileRouteGraph(root, fixtureConfig({ + hooks: { sessionStart: { handler: './src/hooks/session-start.ts' } }, + scripts: { + 'detect-risk': { entry: './src/scripts/detect-risk.ts', targets: ['portable'] }, + 'verify-release': './src/scripts/verify-release.ts', + }, + })); + + expect(graph.diagnostics).toEqual([]); + // Only the unclaimed module is a route; the claimed ones belong to their declarations. + expect(graph.scripts.map((route) => route.id)).toEqual(['script:rebuild-index']); + + // A fully claimed tree compiles the empty graph, so discovery attaches none. + const claimedRoot = await createRoot(); + await writeTree(claimedRoot, { 'src/scripts/check-service-fixture.ts': moduleSource }); + const discovered = await discoverProject(claimedRoot, fixtureConfig({ + scripts: { 'check-service-fixture': './src/scripts/check-service-fixture.ts' }, + })); + expect('routeGraph' in discovered).toBe(false); +}); + +it('errors with AB4800 when a declared entry, command, or url claims a routed server', async () => { + const root = await createRoot(); + await writeTree(root, { 'src/mcp/curator/tools/inspect.ts': moduleSource }); + const graph = await compileRouteGraph(root, fixtureConfig({ + mcp: { servers: { curator: { url: 'https://example.test/mcp' } } }, + })); + + expect(codesOf(graph.diagnostics)).toEqual(['AB4800']); + expect(graph.servers[0]).toMatchObject({ mode: 'conflict' }); + expect(graph.servers[0]!.routes.map((route) => route.id)).toEqual(['tool:curator/inspect']); +}); + +it('errors with AB4800 when an entry module and route modules claim one MCP server, and inspect turns invalid', async () => { + const files = { + 'src/mcp/curator.ts': moduleSource, + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }; + const root = await createRoot(); + await writeTree(root, files); + const graph = await compileRouteGraph(root, fixtureConfig()); + expect(codesOf(graph.diagnostics)).toEqual(['AB4800']); + // Discovery is not a packaging choice: the conflicting surface keeps its routes. + expect(graph.servers[0]).toMatchObject({ mode: 'conflict' }); + expect(graph.servers[0]!.routes.map((route) => route.id)).toEqual(['tool:curator/inspect']); + + const project = await createInspectProject(files); + const result = await inspect({ root: project }); + expect(result.state).toBe('invalid'); + expect(codesOf(result.diagnostics)).toContain('AB4800'); +}); + +it('keeps routes and silences AB4800 under an explicit generated mode', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/curator.ts': moduleSource, + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + const graph = await compileRouteGraph(root, fixtureConfig({ + routes: { servers: { curator: 'generated' } }, + })); + + expect(graph.diagnostics).toEqual([]); + expect(graph.servers[0]).toMatchObject({ mode: 'generated' }); + expect(graph.servers[0]!.routes.map((route) => route.id)).toEqual(['tool:curator/inspect']); +}); + +it('omits a server\'s routes and silences AB4800 under an explicit custom mode', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/curator.ts': moduleSource, + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + const graph = await compileRouteGraph(root, fixtureConfig({ + mcp: { servers: { curator: { entry: './src/mcp/curator.ts' } } }, + routes: { servers: { curator: 'custom' } }, + })); + + expect(graph.diagnostics).toEqual([]); + expect(graph.servers[0]).toMatchObject({ id: 'mcp:curator', mode: 'custom' }); + expect(graph.servers[0]!.routes).toEqual([]); +}); + +it('errors with AB4801 when the conventional CLI entry and command routes both exist', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli.ts': moduleSource, + 'src/cli/doctor.tsx': moduleSource, + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(codesOf(graph.diagnostics)).toEqual(['AB4801']); + expect(graph.cli).toMatchObject({ mode: 'conflict' }); + expect(graph.cli!.routes.map((route) => route.id)).toEqual(['cli:doctor']); +}); + +it('errors with AB4802 when two route modules derive one id', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/mcp/curator/tools/inspect.ts': moduleSource, + 'src/mcp/curator/tools/inspect.tsx': moduleSource, + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(codesOf(graph.diagnostics)).toEqual(['AB4802']); + expect(graph.diagnostics[0]!.message).toContain('src/mcp/curator/tools/inspect.ts'); + expect(graph.diagnostics[0]!.message).toContain('src/mcp/curator/tools/inspect.tsx'); + expect(graph.servers[0]!.routes).toHaveLength(1); +}); + +it('errors with AB4803 on unsafe identity segments', async () => { + const root = await createRoot(); + await writeTree(root, { + 'src/cli/-doctor.ts': moduleSource, + 'src/mcp/bad name/tools/inspect.ts': moduleSource, + }); + const graph = await compileRouteGraph(root, fixtureConfig()); + + expect(codesOf(graph.diagnostics)).toEqual(['AB4803', 'AB4803']); + expect(graph.servers).toEqual([]); + expect(graph.cli).toBeUndefined(); +}); + +it('errors with AB4804 on invalid routes mode overrides', async () => { + const root = await createRoot(); + await writeTree(root, { 'src/mcp/curator/tools/inspect.ts': moduleSource }); + const graph = await compileRouteGraph(root, fixtureConfig({ + routes: { cli: 42, servers: { curator: 'bogus' } }, + })); + + expect(codesOf(graph.diagnostics)).toEqual(['AB4804', 'AB4804']); + // The invalid override is ignored; the conflict-free server stays generated. + expect(graph.servers[0]).toMatchObject({ mode: 'generated' }); +}); + +it('attaches no routeGraph key to a route-free discovered project', async () => { + const root = await createRoot(); + await writeTree(root, { + 'skills/review/SKILL.md': '---\nname: review\ndescription: Reviews changes\n---\n# Review\n', + }); + const discovered = await discoverProject(root, fixtureConfig()); + + expect(discovered.skills).toHaveLength(1); + expect('routeGraph' in discovered).toBe(false); +}); + +it('serves the shared empty graph under the routes focus of a route-free project', async () => { + const root = await createInspectProject({ + 'src/index.ts': 'export const library = true;\n', + }); + const compiled = await compileRouteGraph(root, fixtureConfig()); + expect(isEmptyRouteGraph(compiled)).toBe(true); + expect(compiled.digest).toBe(emptyCompiledRouteGraph.digest); + + const result = await inspect({ focus: 'routes', root }); + + expect(result.state).toBe('ready'); + const routes = (result as ReadyInspectResult).selected?.routes; + expect(routes).toMatchObject({ diagnostics: [], events: [], providers: [], scripts: [], servers: [] }); + expect(routes!.digest).toBe(emptyCompiledRouteGraph.digest); + expect(isEmptyRouteGraph(routes!)).toBe(true); +}); + +it('selects the compiled graph under the routes inspect focus', async () => { + const root = await createInspectProject({ + 'src/mcp/curator/tools/inspect.ts': moduleSource, + 'src/scripts/rebuild-index.ts': moduleSource, + }); + const result = await inspect({ focus: 'routes', root }); + + expect(result.state).toBe('ready'); + const routes = (result as ReadyInspectResult).selected?.routes; + expect(routes).toBeDefined(); + expect(routes!.servers[0]!.routes.map((route) => route.id)).toEqual(['tool:curator/inspect']); + expect(routes!.scripts.map((route) => route.id)).toEqual(['script:rebuild-index']); + expect(routes!.digest).toMatch(/^[a-f\d]{64}$/u); +}); + +it('dumps the graph through the CLI --routes focus and rejects ambiguous focuses', async () => { + Object.defineProperty(globalThis, '__AGENT_BUNDLE_VERSION__', { configurable: true, value: 'test' }); + const root = await createInspectProject({ + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + const stdout: string[] = []; + const code = await runCli(['inspect', '--root', root, '--routes', '--json'], { + stderr: { write: () => undefined }, + stdout: { write: (chunk: string) => stdout.push(chunk) }, + }); + expect(code).toBe(0); + const document = JSON.parse(stdout.join('')) as ReadyInspectResult; + expect(document.selected?.routes?.servers?.[0]).toMatchObject({ id: 'mcp:curator', mode: 'generated' }); + + const stderr: string[] = []; + const ambiguous = await runCli(['inspect', '--root', root, '--routes', '--skills'], { + stderr: { write: (chunk: string) => stderr.push(chunk) }, + stdout: { write: () => undefined }, + }); + expect(ambiguous).toBe(1); + expect(stderr.join('')).toContain('Choose at most one inspect focus.'); +}); + +it('evaluates a stateful config factory once when inspecting the routes focus', async () => { + const root = await createRoot(); + const counterPath = join(root, 'config-load-count.txt'); + await writeTree(root, { + 'agent-bundle.config.ts': [ + "import { readFileSync, writeFileSync } from 'node:fs';", + "import { join } from 'node:path';", + '', + 'export default (ctx: { readonly projectRoot: string }) => {', + " const path = join(ctx.projectRoot, 'config-load-count.txt');", + " writeFileSync(path, `${Number(readFileSync(path, 'utf8')) + 1}\\n`);", + ' return {', + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable'],", + ' };', + '};', + '', + ].join('\n'), + 'config-load-count.txt': '0\n', + 'package.json': '{"type":"module"}\n', + 'src/mcp/curator/tools/inspect.ts': moduleSource, + }); + + const result = await inspect({ focus: 'routes', root }); + expect(result.state).toBe('ready'); + expect(Number(await readFile(counterPath, 'utf8'))).toBe(1); + + const routes = (result as ReadyInspectResult).selected?.routes; + const discovered = await discoverProject(root, fixtureConfig()); + expect(routes).toBeDefined(); + expect(discovered.routeGraph).toBeDefined(); + expect(routes!.digest).toBe(discovered.routeGraph!.digest); + expect(routes!.servers[0]!.routes.map((route) => route.id)).toEqual( + discovered.routeGraph!.servers[0]!.routes.map((route) => route.id), + ); +});