diff --git a/.changeset/route-graph-substrate.md b/.changeset/route-graph-substrate.md new file mode 100644 index 000000000..656b236c9 --- /dev/null +++ b/.changeset/route-graph-substrate.md @@ -0,0 +1,13 @@ +--- +"agent-bundle": patch +--- + +Add the #93 route-compiler substrate: deterministic discovery of route +modules under the conventional roots (`src/mcp//{tools,resources,prompts,apps}/`, +`src/events/`, `src/providers/`, `src/cli/`, `src/scripts/`) compiled into an +immutable, consumer-invisible route graph, a new `AB480x` diagnostic family +for mode conflicts (route directory versus entry-file conventions, duplicate +route ids, unsafe route names), and an `inspect --routes` focus that lists +the discovered graph. Modules explicit configuration already claims are never +routes, so existing layouts keep working unchanged; nothing generates entries +or registries from the graph yet. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 319f9ffcf..56ba84330 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -23,6 +23,7 @@ gate a build, a validation, or a dev rebuild. | `AB472x` | The `tools.rsbuild` / `tools.rspack` escape hatch. | | `AB473x` | Migration nudges (informational; see below). | | `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | +| `AB480x` | Filesystem route conventions (#93 substrate): route-directory versus entry-file mode conflicts (`AB4800`: `src/mcp//` routes with a `src/mcp/.ts` entry, `AB4801`: route-mode server also declared in `mcp.servers`, `AB4802`: `src/cli/` routes with `src/cli.ts`), `AB4803`: duplicate route ids, `AB4804`: unsafe route names. All errors: a server (and the package CLI) is in exactly one mode, and the compiler never silently chooses. | | `AB5000` | General CLI and adapter failures. | | `AB7xxx` | Project preparation and development rebuilds. | | `AB8xxx` | Development server configuration. | 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..527e12fda 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,11 @@ Date: 2026-08-25 Status: proposed +Status note (2026-08-31): the capability-aware Workbench described here has +been implemented in its current form; its evolution onto the compiled route +manifest continues under +[issue #105](https://github.com/ScriptedAlchemy/agent-bundle/issues/105). + ## 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..daa61b42f 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,7 @@ 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):** the meta-framework route compiler + ([issue #93](https://github.com/ScriptedAlchemy/agent-bundle/issues/93)) + deliberately adopts convention-driven filesystem routes; this non-goal no + longer binds future work. diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 8057c2bf0..e4fb221ce 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -34,6 +34,8 @@ export { serializeArtifactManifest, } from './build/manifest.ts'; import { composeBundlerInspection, type BundlerInspection } from './build/inspect-bundler.ts'; +import { emptyAgentRouteGraph } from './routes/types.ts'; +import type { AgentRouteGraph } from './routes/types.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'; @@ -199,7 +201,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 +213,7 @@ export interface ReadyInspectResult { readonly selected?: { readonly bundler?: BundlerInspection; readonly hooks?: NormalizedPlugin['hooks']; + readonly routes?: AgentRouteGraph; readonly skills?: NormalizedPlugin['skills']; }; readonly state: 'ready'; @@ -477,6 +480,7 @@ export const inspect = async (options: InspectOptions): Promise = : Object.freeze({ ...(bundler === undefined ? {} : { bundler }), ...(options.focus === 'hooks' ? { hooks: model.hooks } : {}), + ...(options.focus === 'routes' ? { routes: prepared.routeGraph ?? emptyAgentRouteGraph } : {}), ...(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 bb5d65c2d..6bc0ea0b7 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -71,6 +71,7 @@ interface InspectCommandOptions { readonly json?: boolean; readonly mode?: string; readonly root: string; + readonly routes?: boolean; readonly skills?: boolean; readonly target?: string; } @@ -214,6 +215,17 @@ const writeHumanInspect = (output: Output, result: Awaited plan.target).join(', ')}\n`); }; @@ -409,9 +421,10 @@ export const runCli = async ( ) .option('--bundler', 'Include the synthesized bundler configuration focus') .option('--hooks', 'Include the hook focus') + .option('--routes', 'Include the 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.'); } @@ -420,6 +433,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..b543b8eef 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 { discoverRouteGraph } from '../routes/discover.ts'; +import type { AgentRouteGraph } 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,14 @@ export interface DiscoveredPayload { export interface DiscoveredProject { assets?: DiscoveredAsset[]; payloads?: DiscoveredPayload[]; + /** + * The immutable filesystem route graph (#93 substrate), compiled from the + * conventional route roots with the same sorted, ignore-aware discovery the + * rest of the project uses. Absent when no conventional route module + * matches and no route diagnostic fires — the state of every project that + * has not adopted route conventions. + */ + routeGraph?: AgentRouteGraph; /** * Conventional `skills//SKILL.md` documents that explicit `skills` * configuration leaves uncovered — the confusable shadowed state surfaced @@ -229,9 +239,11 @@ export const discoverProject = async ( const shadowedConventionalSkills = [...shadowedByDir.values()]; const payloads = await discoverPayloads(projectRoot, config.payload); + const routeGraph = await discoverRouteGraph(projectRoot, config, rules); return { assets: await discoverAssets(projectRoot, config.assets, rules), ...(payloads.length === 0 ? {} : { payloads }), + ...(routeGraph.routes.length === 0 && routeGraph.diagnostics.length === 0 ? {} : { 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 b21489d84..c2d49a893 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1397,6 +1397,9 @@ export const validateSource = ( diagnostics.push(...validateTools(loaded)); diagnostics.push(...packageConventionShadowNudges(loaded)); diagnostics.push(...skillConventionShadowNudges(loaded, discovered)); + // Route-graph diagnostics (AB4800-AB4804): mode conflicts, duplicate route + // ids, and unsafe route names computed once at discovery (#93 substrate). + 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 5b1482151..6e8da0806 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -27,6 +27,7 @@ import type { NormalizedMcpServer, NormalizedPlugin, } from '../core/types.ts'; +import type { AgentRouteGraph } from '../routes/types.ts'; import type { DevRuntimePreparedMcpApp, DevRuntimePreparedMcpServer, DevRuntimePreparedProject } from './runtime-provider.ts'; import { freezeJsonValue, type JsonObject, type JsonValue, type SourceStatus } from './types.ts'; @@ -59,6 +60,8 @@ export interface PreparedProject { readonly projectContext?: ProjectContext; readonly registry: TargetRegistry; readonly root: string; + /** The immutable filesystem route graph (#93 substrate); absent when no conventional route module matches. */ + readonly routeGraph?: AgentRouteGraph; /** * Re-snapshots the project with the same output and payload roots the * prepared identity hashed; a divergent re-snapshot would make @@ -534,6 +537,7 @@ const preparedProject = ( devRuntimeDiagnostic?: Diagnostic, devAgentApiEnabled?: boolean, tools?: AgentBundleToolsConfig, + routeGraph?: AgentRouteGraph, ): PreparedProject => Object.freeze({ configPath, ...(devAgentApiEnabled === true ? { devAgentApiEnabled } : {}), @@ -545,6 +549,7 @@ const preparedProject = ( ...(projectContext === undefined ? {} : { projectContext }), registry, root, + ...(routeGraph === undefined ? {} : { routeGraph }), snapshotSource, source, ...(tools === undefined ? {} : { tools }), @@ -854,6 +859,7 @@ export class ProjectService { devRuntimeDiagnostic, devAgentApiEnabled, tools, + discovered.routeGraph, ); } } diff --git a/packages/agent-bundle/src/routes/discover.ts b/packages/agent-bundle/src/routes/discover.ts new file mode 100644 index 000000000..7a38f5f59 --- /dev/null +++ b/packages/agent-bundle/src/routes/discover.ts @@ -0,0 +1,246 @@ +import { relative, resolve } from 'node:path'; + +import fastGlob from 'fast-glob'; + +import { isProjectPathIgnored, type readProjectIgnoreRules } from '../config/ignore.ts'; +import { conventionalCliEntrySource, conventionalMcpEntrySource } from '../config/normalize.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import { isSafePathSegment } from '../core/paths.ts'; +import { isRecord } from '../core/strict-json.ts'; +import type { AgentBundleConfig } from '../core/types.ts'; +import { describeRouteKind, type AgentRouteGraph, type AgentRouteKind, type CompiledAgentRoute } from './types.ts'; + +type ProjectIgnoreRules = Awaited>; + +/** + * The conventional route roots (#93). A recognized module beneath one of + * these roots is an application declaration: its path supplies route kind, + * owning server, and identity. MCP category directories and the flat + * provider/cli/scripts roots match exactly one level; event routes nest. + */ +const routeModulePatterns = [ + 'src/cli/*.{ts,tsx}', + 'src/events/**/*.{ts,tsx}', + 'src/mcp/*/apps/*.{ts,tsx}', + 'src/mcp/*/prompts/*.{ts,tsx}', + 'src/mcp/*/resources/*.{ts,tsx}', + 'src/mcp/*/tools/*.{ts,tsx}', + 'src/providers/*.{ts,tsx}', + 'src/scripts/*.{ts,tsx}', +] as const; + +const mcpCategoryKinds: Readonly> = Object.freeze({ + apps: 'app', + prompts: 'prompt', + resources: 'resource', + tools: 'tool', +}); + +interface ClassifiedRoutePath { + readonly conventionalRoot: string; + readonly kind: AgentRouteKind; + readonly serverId?: string; +} + +/** Derives kind, owning server, and conventional root from one project-relative POSIX path. */ +const classifyRoutePath = (relativePath: string): ClassifiedRoutePath | undefined => { + const segments = relativePath.split('/'); + if (segments[0] !== 'src' || segments.length < 3) return undefined; + const root = segments[1]; + if (root === 'mcp' && segments.length === 5) { + const kind = mcpCategoryKinds[segments[3]!]; + if (kind === undefined) return undefined; + return { + conventionalRoot: segments.slice(0, 4).join('/'), + kind, + serverId: segments[2]!, + }; + } + if (root === 'events') return { conventionalRoot: 'src/events', kind: 'event-route' }; + if (segments.length !== 3) return undefined; + if (root === 'providers') return { conventionalRoot: 'src/providers', kind: 'provider' }; + if (root === 'cli') return { conventionalRoot: 'src/cli', kind: 'cli' }; + if (root === 'scripts') return { conventionalRoot: 'src/scripts', kind: 'script' }; + return undefined; +}; + +const routeModuleExtensionPattern = /\.(?:ts|tsx)$/u; + +/** `_`-prefixed files and directories are private by convention and never routes. */ +const hasPrivateSegment = (relativePath: string): boolean => + relativePath.split('/').some((segment) => 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 that explicit configuration already claims. Config + * always wins — the rule the entry conventions established — so a source an + * explicit `scripts`, `hooks`, `bin`, `lib`, or `mcp` declaration references + * is that declaration's module, never a conventional route. Two shipped + * examples declare `scripts` entries under `src/scripts/`; this rule keeps + * their layouts valid without a migration. + */ +export const configClaimedSources = ( + projectRoot: string, + config: AgentBundleConfig, +): 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)); + }; + if (isRecord(config.scripts)) { + for (const value of Object.values(config.scripts)) claim(value); + } + if (isRecord(config.hooks)) { + for (const input of Object.values(config.hooks)) { + for (const rawEntry of Array.isArray(input) ? input : [input]) { + claim(typeof rawEntry === 'string' ? rawEntry : isRecord(rawEntry) ? rawEntry.handler : undefined); + } + } + } + if (isRecord(config.bin)) { + for (const value of Object.values(config.bin)) claim(value); + } + claim(config.lib); + const servers = isRecord(config.mcp) && isRecord(config.mcp.servers) ? config.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; +}; + +/** Server names explicit `mcp.servers` configuration declares, whatever their mode. */ +const configDeclaredServers = (config: AgentBundleConfig): ReadonlySet => { + const servers = isRecord(config.mcp) && isRecord(config.mcp.servers) ? config.mcp.servers : undefined; + return new Set(Object.keys(servers ?? {})); +}; + +const routeDiagnostic = ( + code: string, + message: string, + sourcePath: string, + recovery: string, +): Diagnostic => ({ code, message, recovery, severity: 'error', sourcePath }); + +/** + * Discovers the conventional route modules and compiles the immutable route + * graph (#93 substrate). Discovery rides the repository's deterministic + * machinery: sorted globbing, project ignore rules, safe path segments, and + * provenance on every route. Mode conflicts are hard errors — a server (and + * the package CLI) is in exactly one mode, and the compiler never silently + * chooses one (issue rule 10). + */ +export const discoverRouteGraph = async ( + projectRoot: string, + config: AgentBundleConfig, + rules: ProjectIgnoreRules, +): Promise => { + const matches = await fastGlob([...routeModulePatterns], { + absolute: true, + cwd: projectRoot, + followSymbolicLinks: false, + onlyFiles: true, + }); + const claimed = configClaimedSources(projectRoot, config); + const sources = [...new Set(matches)] + .filter((source) => + !source.endsWith('.d.ts') && + !claimed.has(source) && + !isProjectPathIgnored(rules, projectRoot, source)) + .sort((left, right) => left.localeCompare(right)); + + const diagnostics: Diagnostic[] = []; + const routes: CompiledAgentRoute[] = []; + const sourcesById = new Map(); + for (const source of sources) { + const relativePath = relative(projectRoot, source).replaceAll('\\', '/'); + if (hasPrivateSegment(relativePath)) continue; + const classified = classifyRoutePath(relativePath); + if (classified === undefined) continue; + const stem = relativePath.replace(routeModuleExtensionPattern, ''); + const segments = stem.split('/').slice(1); + if (!segments.every(isSafePathSegment)) { + diagnostics.push(routeDiagnostic( + 'AB4804', + `Route module ${relativePath} has an unsafe path segment; route names use alphanumeric-leading segments of letters, digits, ".", "_", and "-".`, + source, + 'Rename the route module (and any unsafe parent directories) to safe path segments, then inspect again.', + )); + continue; + } + const id = segments.join('/'); + const existing = sourcesById.get(id); + if (existing !== undefined) { + diagnostics.push(routeDiagnostic( + 'AB4803', + `Route id ${JSON.stringify(id)} of ${relativePath} duplicates ${relative(projectRoot, existing).replaceAll('\\', '/')}.`, + source, + 'Keep exactly one module per route id; remove or rename the duplicate, then inspect again.', + )); + continue; + } + sourcesById.set(id, source); + routes.push({ + config: {}, + id, + kind: classified.kind, + provenance: { + conventionalRoot: classified.conventionalRoot, + kind: 'conventional', + relativePath, + }, + ...(classified.serverId === undefined ? {} : { serverId: classified.serverId }), + source, + }); + } + + const servers = [...new Set(routes.flatMap((route) => (route.serverId === undefined ? [] : [route.serverId])))] + .sort((left, right) => left.localeCompare(right)); + const declaredServers = configDeclaredServers(config); + for (const serverId of servers) { + const fileEntry = conventionalMcpEntrySource(projectRoot, serverId); + if (fileEntry !== undefined) { + diagnostics.push(routeDiagnostic( + 'AB4800', + `MCP server ${JSON.stringify(serverId)} has route modules under src/mcp/${serverId}/ and the conventional entry ${relative(projectRoot, fileEntry).replaceAll('\\', '/')}; a server is in exactly one mode.`, + fileEntry, + `Choose one mode explicitly: remove the src/mcp/${serverId}/ route modules to keep the entry file, or remove the entry file to adopt generated routes.`, + )); + } + if (declaredServers.has(serverId)) { + diagnostics.push(routeDiagnostic( + 'AB4801', + `MCP server ${JSON.stringify(serverId)} has route modules under src/mcp/${serverId}/ but mcp.servers.${serverId} is also declared in configuration; a server is in exactly one mode.`, + resolve(projectRoot, 'src', 'mcp', serverId), + `Choose one mode explicitly: remove the mcp.servers.${serverId} declaration to adopt generated routes, or remove the src/mcp/${serverId}/ route modules to keep the declared server.`, + )); + } + } + if (routes.some((route) => route.kind === 'cli')) { + const cliEntry = conventionalCliEntrySource(projectRoot); + if (cliEntry !== undefined) { + diagnostics.push(routeDiagnostic( + 'AB4802', + `${describeRouteKind('cli')} modules live under src/cli/ but the conventional package bin entry ${relative(projectRoot, cliEntry).replaceAll('\\', '/')} also exists; the package CLI is in exactly one mode.`, + cliEntry, + 'Choose one mode explicitly: remove the src/cli/ route modules to keep the entry file, or remove the entry file to adopt routed CLI commands.', + )); + } + } + + routes.sort((left, right) => left.id.localeCompare(right.id)); + return deepFreeze({ diagnostics, routes, 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..3eec851b6 --- /dev/null +++ b/packages/agent-bundle/src/routes/index.ts @@ -0,0 +1,9 @@ +export { configClaimedSources, discoverRouteGraph } from './discover.ts'; +export { + describeRouteKind, + emptyAgentRouteGraph, + type AgentRouteGraph, + type AgentRouteKind, + type CompiledAgentRoute, + type RouteProvenance, +} from './types.ts'; diff --git a/packages/agent-bundle/src/routes/types.ts b/packages/agent-bundle/src/routes/types.ts new file mode 100644 index 000000000..b0e4d555a --- /dev/null +++ b/packages/agent-bundle/src/routes/types.ts @@ -0,0 +1,93 @@ +import type { Diagnostic } from '../core/diagnostics.ts'; + +/** + * Route kinds recognized under the conventional source roots (#93). The + * issue's `CompiledAgentRoute` union lists seven kinds explicitly; + * `provider` covers the `src/providers/*` conventional root the same issue + * declares alongside them. + */ +export type AgentRouteKind = + | 'tool' + | 'resource' + | 'prompt' + | 'app' + | 'event-route' + | 'provider' + | 'cli' + | 'script'; + +/** + * Where one route came from. This wave discovers routes only through the + * conventional roots; explicit per-route overrides arrive with the public + * authoring surface and will add their own provenance kind. + */ +export interface RouteProvenance { + /** The project-relative POSIX directory of the conventional root that matched. */ + readonly conventionalRoot: string; + readonly kind: 'conventional'; + /** The project-relative POSIX path of the route module. */ + readonly relativePath: string; +} + +/** + * One entry of the immutable route graph — #93's `CompiledAgentRoute`. The + * filesystem path supplies kind, owning server, and identity. The `config` + * field stays empty until the static `config`-export extractor lands; the + * compiler never fabricates metadata a route module did not declare. + */ +export interface CompiledAgentRoute { + readonly config: Readonly>; + /** Path-derived stable identity: the `src/`-relative POSIX path without its extension. */ + readonly id: string; + readonly kind: AgentRouteKind; + readonly provenance: RouteProvenance; + /** The owning generated MCP server; present only for tool/resource/prompt/app routes. */ + readonly serverId?: string; + /** The absolute path of the route module. */ + readonly source: string; +} + +/** + * The immutable route graph. Deterministic (sorted routes and servers), + * deep-frozen, and consumer-invisible this wave: nothing generates entries + * or registries from it yet. + */ +export interface AgentRouteGraph { + readonly diagnostics: readonly Diagnostic[]; + readonly routes: readonly CompiledAgentRoute[]; + /** Sorted ids of MCP servers that own at least one generated route. */ + readonly servers: readonly string[]; +} + +/** The graph of a project without conventional route modules. */ +export const emptyAgentRouteGraph: AgentRouteGraph = Object.freeze({ + diagnostics: Object.freeze([]), + routes: Object.freeze([]), + servers: Object.freeze([]), +}); + +/** The human noun for one route kind, shared by diagnostics and inspect output. */ +export const describeRouteKind = (kind: AgentRouteKind): string => { + switch (kind) { + case 'tool': + return 'tool'; + case 'resource': + return 'resource'; + case 'prompt': + return 'prompt'; + case 'app': + return 'MCP App'; + case 'event-route': + return 'event route'; + case 'provider': + return 'context provider'; + case 'cli': + return 'CLI command'; + case 'script': + return 'script'; + default: { + const unreachable: never = kind; + throw new Error(`Unhandled route kind ${String(unreachable)}.`); + } + } +}; diff --git a/packages/agent-bundle/tests/routes.test.ts b/packages/agent-bundle/tests/routes.test.ts new file mode 100644 index 000000000..6c90b3b8d --- /dev/null +++ b/packages/agent-bundle/tests/routes.test.ts @@ -0,0 +1,254 @@ +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { inspect, validate } from '../src/api.ts'; +import { discoverProject } from '../src/config/index.ts'; +import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; + +const routeModule = 'export default async () => null;\n'; + +const fixtureConfig = (body: string): string => [ + 'export default {', + " plugin: { name: 'routes-fixture', version: '1.0.0' },", + " targets: ['portable'],", + body, + '};', + '', +].join('\n'); + +const minimalConfig = { plugin: { name: 'routes-fixture', version: '1.0.0' } }; + +it('compiles an immutable route graph from every conventional root', async () => { + const fixture = await createProjectFixture({ + config: fixtureConfig(''), + files: { + 'src/cli/doctor.tsx': routeModule, + 'src/events/file/saved.tsx': routeModule, + 'src/mcp/curator/apps/dashboard.tsx': routeModule, + 'src/mcp/curator/prompts/curate.tsx': routeModule, + 'src/mcp/curator/resources/catalog.ts': routeModule, + 'src/mcp/curator/tools/inspect.tsx': routeModule, + 'src/mcp/other/tools/convert.ts': routeModule, + 'src/providers/git-worktree.ts': routeModule, + 'src/scripts/rebuild-index.ts': routeModule, + }, + prefix: 'agent-bundle-routes-', + }); + try { + const discovered = await discoverProject(fixture.root, minimalConfig); + const graph = discovered.routeGraph; + expect(graph).toBeDefined(); + expect(graph!.diagnostics).toEqual([]); + expect(graph!.servers).toEqual(['curator', 'other']); + expect(graph!.routes.map((route) => [route.kind, route.id, route.serverId])).toEqual([ + ['cli', 'cli/doctor', undefined], + ['event-route', 'events/file/saved', undefined], + ['app', 'mcp/curator/apps/dashboard', 'curator'], + ['prompt', 'mcp/curator/prompts/curate', 'curator'], + ['resource', 'mcp/curator/resources/catalog', 'curator'], + ['tool', 'mcp/curator/tools/inspect', 'curator'], + ['tool', 'mcp/other/tools/convert', 'other'], + ['provider', 'providers/git-worktree', undefined], + ['script', 'scripts/rebuild-index', undefined], + ]); + const tool = graph!.routes.find((route) => route.id === 'mcp/curator/tools/inspect')!; + expect(tool.source).toBe(join(fixture.root, 'src/mcp/curator/tools/inspect.tsx')); + expect(tool.config).toEqual({}); + expect(tool.provenance).toEqual({ + conventionalRoot: 'src/mcp/curator/tools', + kind: 'conventional', + relativePath: 'src/mcp/curator/tools/inspect.tsx', + }); + expect(Object.isFrozen(graph)).toBe(true); + expect(Object.isFrozen(graph!.routes)).toBe(true); + expect(Object.isFrozen(tool)).toBe(true); + expect(Object.isFrozen(tool.config)).toBe(true); + expect(Object.isFrozen(tool.provenance)).toBe(true); + } finally { + await removeProjectFixture(fixture.root); + } +}); + +it('bounds discovery: private, ignored, declaration, claimed, and non-route files never become routes', async () => { + const fixture = await createProjectFixture({ + config: fixtureConfig(''), + files: { + '.gitignore': 'src/scripts/generated.ts\n', + 'src/cli.ts': 'export const main = async () => 0;\n', + // Deeply nested tool files and loose server files are not route shapes. + 'src/mcp/curator/tools/nested/too-deep.ts': routeModule, + 'src/mcp/curator/shared.ts': 'export const shared = true;\n', + 'src/providers/_private.ts': routeModule, + 'src/providers/kinds.d.ts': 'export type Kind = string;\n', + 'src/scripts/generated.ts': routeModule, + 'src/scripts/release.ts': routeModule, + }, + prefix: 'agent-bundle-routes-', + }); + try { + // The explicit scripts declaration claims release.ts: config always wins, + // so the module belongs to the declaration, not the route convention. + const discovered = await discoverProject(fixture.root, { + ...minimalConfig, + scripts: { release: './src/scripts/release.ts' }, + }); + expect(discovered.routeGraph).toBeUndefined(); + } finally { + await removeProjectFixture(fixture.root); + } +}); + +it('reports AB4800 when a server has both route modules and a conventional entry file', async () => { + const fixture = await createProjectFixture({ + config: fixtureConfig(''), + files: { + 'src/mcp/curator.ts': 'export default () => null;\n', + 'src/mcp/curator/tools/inspect.ts': routeModule, + }, + prefix: 'agent-bundle-routes-', + }); + try { + const discovered = await discoverProject(fixture.root, minimalConfig); + expect(discovered.routeGraph?.diagnostics).toMatchObject([{ + code: 'AB4800', + severity: 'error', + sourcePath: join(fixture.root, 'src/mcp/curator.ts'), + }]); + // The graph still lists the discovered route; the diagnostic gates use. + expect(discovered.routeGraph?.routes.map((route) => route.id)).toEqual(['mcp/curator/tools/inspect']); + } finally { + await removeProjectFixture(fixture.root); + } +}); + +it('reports AB4801 when a route-mode server is also declared in configuration', async () => { + const fixture = await createProjectFixture({ + config: fixtureConfig(" mcp: { servers: { curator: { url: 'https://example.test/mcp' } } },"), + files: { + 'src/mcp/curator/tools/inspect.ts': routeModule, + }, + prefix: 'agent-bundle-routes-', + }); + try { + const discovered = await discoverProject(fixture.root, { + ...minimalConfig, + mcp: { servers: { curator: { url: 'https://example.test/mcp' } } }, + }); + expect(discovered.routeGraph?.diagnostics).toMatchObject([{ + code: 'AB4801', + severity: 'error', + }]); + // End to end: source validation carries the mode conflict as an error. + const validated = await validate({ root: fixture.root }); + expect(validated.diagnostics.filter((diagnostic) => diagnostic.code === 'AB4801')).toHaveLength(1); + } finally { + await removeProjectFixture(fixture.root); + } +}); + +it('reports AB4802 when routed CLI commands coexist with the src/cli.ts convention', async () => { + const fixture = await createProjectFixture({ + config: fixtureConfig(''), + files: { + 'src/cli.ts': 'export const main = async () => 0;\n', + 'src/cli/doctor.ts': routeModule, + }, + prefix: 'agent-bundle-routes-', + }); + try { + const discovered = await discoverProject(fixture.root, minimalConfig); + expect(discovered.routeGraph?.diagnostics).toMatchObject([{ + code: 'AB4802', + severity: 'error', + sourcePath: join(fixture.root, 'src/cli.ts'), + }]); + } finally { + await removeProjectFixture(fixture.root); + } +}); + +it('reports AB4803 for duplicate route ids and keeps the first module', async () => { + const fixture = await createProjectFixture({ + config: fixtureConfig(''), + files: { + 'src/scripts/index-library.ts': routeModule, + 'src/scripts/index-library.tsx': routeModule, + }, + prefix: 'agent-bundle-routes-', + }); + try { + const discovered = await discoverProject(fixture.root, minimalConfig); + expect(discovered.routeGraph?.diagnostics).toMatchObject([{ + code: 'AB4803', + severity: 'error', + sourcePath: join(fixture.root, 'src/scripts/index-library.tsx'), + }]); + expect(discovered.routeGraph?.routes.map((route) => route.provenance.relativePath)) + .toEqual(['src/scripts/index-library.ts']); + } finally { + await removeProjectFixture(fixture.root); + } +}); + +it('reports AB4804 for unsafe route path segments', async () => { + const fixture = await createProjectFixture({ + config: fixtureConfig(''), + files: { + 'src/scripts/bad name.ts': routeModule, + }, + prefix: 'agent-bundle-routes-', + }); + try { + const discovered = await discoverProject(fixture.root, minimalConfig); + expect(discovered.routeGraph?.diagnostics).toMatchObject([{ + code: 'AB4804', + severity: 'error', + sourcePath: join(fixture.root, 'src/scripts/bad name.ts'), + }]); + expect(discovered.routeGraph?.routes).toEqual([]); + } finally { + await removeProjectFixture(fixture.root); + } +}); + +it('inspect exposes the route graph behind the routes focus', async () => { + const fixture = await createProjectFixture({ + config: fixtureConfig(''), + files: { + 'src/mcp/curator/tools/inspect.tsx': routeModule, + 'src/scripts/rebuild-index.ts': routeModule, + }, + prefix: 'agent-bundle-routes-', + }); + try { + const result = await inspect({ focus: 'routes', root: fixture.root }); + expect(result.state).toBe('ready'); + if (result.state !== 'ready') throw new Error('expected a ready inspection'); + expect(result.selected?.routes?.servers).toEqual(['curator']); + expect(result.selected?.routes?.routes.map((route) => route.id)).toEqual([ + 'mcp/curator/tools/inspect', + 'scripts/rebuild-index', + ]); + } finally { + await removeProjectFixture(fixture.root); + } +}); + +it('inspect returns the empty route graph for a project without route modules', async () => { + const fixture = await createProjectFixture({ + config: fixtureConfig(''), + files: { + 'src/index.ts': 'export const library = true;\n', + }, + prefix: 'agent-bundle-routes-', + }); + try { + const result = await inspect({ focus: 'routes', root: fixture.root }); + expect(result.state).toBe('ready'); + if (result.state !== 'ready') throw new Error('expected a ready inspection'); + expect(result.selected?.routes).toEqual({ diagnostics: [], routes: [], servers: [] }); + } finally { + await removeProjectFixture(fixture.root); + } +});