From 0c56eb22ba6b45913abbdaef116678422277f617 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 27 Jun 2026 16:32:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20registry-driven=20exhaustive=20command?= =?UTF-8?q?=20dispatch=20=E2=80=94=20Phase=201=20step=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part A (enabler): make commandDescriptors `as const` so each entry keeps its literal `name`, and export `Command` — the literal command-name union. Part B: replace the dispatchKnownCommand switch with a `Record` lookup table. The Record type forces every dispatch command to have a handler (a missing entry is now a COMPILE error, replacing the runtime `default: throw` as the coverage net); an `Object.hasOwn` guard preserves the identical INVALID_ARGS error for unknown commands. DispatchCommand is hand-authored to match the switch cases verbatim (it is not the registry `generic` route, and `swipe-preset`/`read` aren't registry names). Strictly behaviorless: same routing, same handler args, same results. --- src/core/command-descriptor/registry.ts | 29 ++-- src/core/dispatch.ts | 208 +++++++++++++++--------- 2 files changed, 149 insertions(+), 88 deletions(-) diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index d0b75a24d..fa539b450 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -6,7 +6,7 @@ import { import type { CommandCapability } from '../capabilities.ts'; import type { DaemonRequest } from '../../daemon/types.ts'; import type { DeviceInfo } from '../../utils/device.ts'; -import { type CommandDescriptor, defineCommandDescriptor } from './types.ts'; +import type { CommandDescriptor } from './types.ts'; // --------------------------------------------------------------------------- // Daemon request-policy trait bundles — copied VERBATIM from @@ -90,7 +90,7 @@ const APP_INSTALL_CAPABILITY = { // live only in the capability/batch hand tables). // --------------------------------------------------------------------------- -const RAW_COMMAND_DESCRIPTORS: readonly Omit[] = [ +const RAW_COMMAND_DESCRIPTORS = [ // -- lease (route: lease) -- { name: INTERNAL_COMMANDS.leaseAllocate, @@ -536,19 +536,22 @@ const RAW_COMMAND_DESCRIPTORS: readonly Omit[] capability: APP_INSTALL_CAPABILITY, batchable: true, }, -]; +] as const satisfies readonly Omit[]; const MCP_EXPOSED_COMMAND_NAMES = new Set(listMcpExposedCommandNames()); /** - * The additive single source of truth (ADR-0008, Phase 1 step 1). Dormant: no - * consumer reads it yet. Proven byte-equal to the live hand tables by - * `__tests__/parity.test.ts`. + * The additive single source of truth (ADR-0008, Phase 1 step 1). Proven + * byte-equal to the live hand tables by `__tests__/parity.test.ts`. + * + * The `as const` on {@link RAW_COMMAND_DESCRIPTORS} flows through this `.map`, + * so each entry keeps its literal `name`. That is what makes the {@link Command} + * union below a precise set of command-name literals rather than `string`. */ -export const commandDescriptors: readonly CommandDescriptor[] = RAW_COMMAND_DESCRIPTORS.map( - (descriptor) => - defineCommandDescriptor({ - ...descriptor, - mcpExposed: MCP_EXPOSED_COMMAND_NAMES.has(descriptor.name), - }), -); +export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => ({ + ...descriptor, + mcpExposed: MCP_EXPOSED_COMMAND_NAMES.has(descriptor.name), +})) satisfies readonly CommandDescriptor[]; + +/** The literal union of every registered command name. */ +export type Command = (typeof commandDescriptors)[number]['name']; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 88b80c060..a9a50b418 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -87,7 +87,130 @@ export async function dispatchCommand( ); } -// fallow-ignore-next-line complexity +/** + * The exact set of commands routed by {@link dispatchKnownCommand}. Hand-authored + * to match the former `switch` cases verbatim: it is NOT the registry's `generic` + * daemon route (that set is both narrower — e.g. it has no `open`/`type`/`read` — + * and includes `gesture`, which dispatch never handled), and `swipe-preset` / + * `read` are not registry command names at all. Keeping it explicit makes the + * dispatch surface self-describing and lets the `Record` below enforce coverage. + */ +type DispatchCommand = + | 'open' + | 'close' + | 'press' + | 'swipe' + | 'swipe-preset' + | 'pan' + | 'fling' + | 'longpress' + | 'focus' + | 'type' + | 'fill' + | 'scroll' + | 'pinch' + | 'rotate-gesture' + | 'transform-gesture' + | 'trigger-app-event' + | 'screenshot' + | 'viewport' + | 'back' + | 'home' + | 'rotate' + | 'app-switcher' + | 'clipboard' + | 'keyboard' + | 'settings' + | 'push' + | 'snapshot' + | 'read'; + +type DispatchHandlerArgs = { + device: DeviceInfo; + interactor: Interactor; + positionals: string[]; + outPath: string | undefined; + context: DispatchContext | undefined; + runnerCtx: RunnerContext; +}; + +type DispatchHandler = (args: DispatchHandlerArgs) => Promise | void>; + +/** + * Registry-driven exhaustive dispatch table. The `Record` + * type forces every dispatch command to have a handler — a missing entry is a + * COMPILE error, which replaces the former runtime `default: throw` as the + * coverage safety net. Each entry routes to the IDENTICAL handler with the + * IDENTICAL arguments the `switch` used, so dispatch stays strictly behaviorless. + */ +const DISPATCH_HANDLERS: Record = { + open: ({ device, interactor, positionals, context }) => + handleOpenCommand(device, interactor, positionals, context), + close: async ({ device, interactor, positionals }) => { + const app = positionals[0]; + if (!app) { + if (device.platform === 'web') { + await interactor.close(''); + } + return { closed: 'session', ...successText('Closed session') }; + } + await interactor.close(app); + return { app, ...successText(`Closed: ${app}`) }; + }, + press: ({ device, interactor, positionals, context }) => + handlePressCommand(device, interactor, positionals, context), + swipe: ({ device, interactor, positionals, context }) => + handleSwipeCommand(device, interactor, positionals, context), + 'swipe-preset': ({ device, interactor, positionals, context }) => + handleSwipePresetCommand(device, interactor, positionals, context), + pan: ({ interactor, positionals }) => handlePanCommand(interactor, positionals), + fling: ({ interactor, positionals }) => handleFlingCommand(interactor, positionals), + longpress: ({ interactor, positionals }) => handleLongPressCommand(interactor, positionals), + focus: ({ interactor, positionals }) => handleFocusCommand(interactor, positionals), + type: ({ interactor, positionals, context }) => + handleTypeCommand(interactor, positionals, context), + fill: ({ interactor, positionals, context }) => + handleFillCommand(interactor, positionals, context), + scroll: ({ interactor, positionals, context }) => + handleScrollCommand(interactor, positionals, context), + pinch: ({ device, interactor, positionals, context }) => + handlePinchCommand(device, interactor, positionals, context), + 'rotate-gesture': ({ device, interactor, positionals }) => + handleRotateGestureCommand(device, interactor, positionals), + 'transform-gesture': ({ device, interactor, positionals }) => + handleTransformGestureCommand(device, interactor, positionals), + 'trigger-app-event': ({ device, interactor, positionals, context }) => + handleTriggerAppEventCommand(device, interactor, positionals, context), + screenshot: ({ interactor, positionals, outPath, context }) => + handleScreenshotCommand(interactor, positionals, outPath, context), + viewport: ({ interactor, positionals }) => handleViewportCommand(interactor, positionals), + back: async ({ interactor, context }) => { + await interactor.back(context?.backMode); + return { action: 'back', mode: context?.backMode ?? 'in-app', ...successText('Back') }; + }, + home: async ({ interactor }) => { + await interactor.home(); + return { action: 'home', ...successText('Home') }; + }, + rotate: async ({ interactor, positionals }) => { + const orientation = parseDeviceRotation(positionals[0]); + await interactor.rotate(orientation); + return { action: 'rotate', orientation, ...successText(`Rotated to ${orientation}`) }; + }, + 'app-switcher': async ({ interactor }) => { + await interactor.appSwitcher(); + return { action: 'app-switcher', ...successText('Opened app switcher') }; + }, + clipboard: ({ interactor, positionals }) => handleClipboardCommand(interactor, positionals), + keyboard: ({ device, positionals, context, runnerCtx }) => + handleKeyboardCommand(device, positionals, context, runnerCtx), + settings: ({ device, interactor, positionals, context }) => + handleSettingsCommand(device, interactor, positionals, context), + push: ({ device, positionals, context }) => handlePushCommand(device, positionals, context), + snapshot: ({ interactor, context }) => handleSnapshotCommand(interactor, context), + read: ({ device, positionals, context }) => handleReadCommand(device, positionals, context), +}; + async function dispatchKnownCommand( device: DeviceInfo, interactor: Interactor, @@ -97,81 +220,16 @@ async function dispatchKnownCommand( context: DispatchContext | undefined, runnerCtx: RunnerContext, ): Promise | void> { - switch (command) { - case 'open': - return await handleOpenCommand(device, interactor, positionals, context); - case 'close': { - const app = positionals[0]; - if (!app) { - if (device.platform === 'web') { - await interactor.close(''); - } - return { closed: 'session', ...successText('Closed session') }; - } - await interactor.close(app); - return { app, ...successText(`Closed: ${app}`) }; - } - case 'press': - return await handlePressCommand(device, interactor, positionals, context); - case 'swipe': - return await handleSwipeCommand(device, interactor, positionals, context); - case 'swipe-preset': - return await handleSwipePresetCommand(device, interactor, positionals, context); - case 'pan': - return await handlePanCommand(interactor, positionals); - case 'fling': - return await handleFlingCommand(interactor, positionals); - case 'longpress': - return await handleLongPressCommand(interactor, positionals); - case 'focus': - return await handleFocusCommand(interactor, positionals); - case 'type': - return await handleTypeCommand(interactor, positionals, context); - case 'fill': - return await handleFillCommand(interactor, positionals, context); - case 'scroll': - return await handleScrollCommand(interactor, positionals, context); - case 'pinch': - return await handlePinchCommand(device, interactor, positionals, context); - case 'rotate-gesture': - return await handleRotateGestureCommand(device, interactor, positionals); - case 'transform-gesture': - return await handleTransformGestureCommand(device, interactor, positionals); - case 'trigger-app-event': - return await handleTriggerAppEventCommand(device, interactor, positionals, context); - case 'screenshot': - return await handleScreenshotCommand(interactor, positionals, outPath, context); - case 'viewport': - return await handleViewportCommand(interactor, positionals); - case 'back': - await interactor.back(context?.backMode); - return { action: 'back', mode: context?.backMode ?? 'in-app', ...successText('Back') }; - case 'home': - await interactor.home(); - return { action: 'home', ...successText('Home') }; - case 'rotate': { - const orientation = parseDeviceRotation(positionals[0]); - await interactor.rotate(orientation); - return { action: 'rotate', orientation, ...successText(`Rotated to ${orientation}`) }; - } - case 'app-switcher': - await interactor.appSwitcher(); - return { action: 'app-switcher', ...successText('Opened app switcher') }; - case 'clipboard': - return await handleClipboardCommand(interactor, positionals); - case 'keyboard': - return await handleKeyboardCommand(device, positionals, context, runnerCtx); - case 'settings': - return await handleSettingsCommand(device, interactor, positionals, context); - case 'push': - return await handlePushCommand(device, positionals, context); - case 'snapshot': - return await handleSnapshotCommand(interactor, context); - case 'read': - return await handleReadCommand(device, positionals, context); - default: - throw new AppError('INVALID_ARGS', `Unknown command: ${command}`); + // `Object.hasOwn` keeps the lookup behaviorless: any unknown command — + // including inherited keys like `toString` — falls through to the same + // `INVALID_ARGS` error the former `default:` branch threw. + const handler = Object.hasOwn(DISPATCH_HANDLERS, command) + ? DISPATCH_HANDLERS[command as DispatchCommand] + : undefined; + if (!handler) { + throw new AppError('INVALID_ARGS', `Unknown command: ${command}`); } + return await handler({ device, interactor, positionals, outPath, context, runnerCtx }); } // ---------------------------------------------------------------------------