From dc8af7e243cf9a3f1a963b11ad9f0f1ea8b1410c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 01:07:43 +0000 Subject: [PATCH] =?UTF-8?q?feat(routes):=20add=20the=20#93=20route-compile?= =?UTF-8?q?r=20substrate=20=E2=80=94=20discovery,=20immutable=20route-grap?= =?UTF-8?q?h=20IR,=20mode=20diagnostics,=20inspect=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover route modules under the conventional roots (src/mcp// {tools,resources,prompts,apps}/, src/events/, src/providers/, src/cli/, src/scripts/) through the existing sorted, ignore-aware, safe-path discovery machinery, and compile them into a deep-frozen AgentRouteGraph whose paths supply kind, owning server, and identity. New AB480x error family: route-directory vs entry-file mode conflicts (AB4800 server file, AB4801 declared server, AB4802 src/cli.ts), duplicate route ids (AB4803), and unsafe route names (AB4804) — a server is in exactly one mode and the compiler never silently chooses. Modules explicit configuration claims (scripts, hooks, bin, lib, mcp entries) are never routes, keeping shipped example layouts valid. inspect gains a --routes focus listing the graph. The graph is consumer-invisible this wave: config stays empty until the static extractor lands, and nothing generates entries from it. Docs rider: supersession note on the 2026-08-26 filesystem-router non-goal pointing at #93; status note on the 2026-08-25 capability-aware Workbench spec pointing at #105. Part of #93 --- .changeset/route-graph-substrate.md | 13 + docs/diagnostics.md | 1 + ...08-25-capability-aware-workbench-design.md | 5 + ...app-and-audiobook-curator-parity-design.md | 4 + packages/agent-bundle/src/api.ts | 6 +- packages/agent-bundle/src/cli.ts | 16 +- packages/agent-bundle/src/config/discover.ts | 12 + packages/agent-bundle/src/config/validate.ts | 3 + .../agent-bundle/src/dev/project-service.ts | 6 + packages/agent-bundle/src/routes/discover.ts | 246 +++++++++++++++++ packages/agent-bundle/src/routes/index.ts | 9 + packages/agent-bundle/src/routes/types.ts | 93 +++++++ packages/agent-bundle/tests/routes.test.ts | 254 ++++++++++++++++++ 13 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 .changeset/route-graph-substrate.md create mode 100644 packages/agent-bundle/src/routes/discover.ts create mode 100644 packages/agent-bundle/src/routes/index.ts create mode 100644 packages/agent-bundle/src/routes/types.ts create mode 100644 packages/agent-bundle/tests/routes.test.ts 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); + } +});