From e69185a0a78d75e9dc43db724b07b13641ec51c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 15:36:42 +0200 Subject: [PATCH] refactor: route capability bucket through PlatformPlugin + pin supports() closures (Phase 3 step b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b.1: isCommandSupportedOnDevice now reads each platform's capability bucket from getPlugin(device.platform).capability.bucket (the PlatformPlugin registry, ADR-0009) instead of the platformDescriptors fold. capabilities.ts registers the builtin plugins at module load (idempotent, lazy closures only) so the admission path populates the registry without depending on core/interactors.ts load order. b.2: the per-command supports()/unsupportedHint() closures stay VERBATIM on the command-descriptor facet; they cannot move to the plugin's per-FAMILY capability.supportsByDefault without flattening their per-command shape (perfect-shape §7). A new table-equivalence parity test pins both the bucket-route swap and the closures byte-for-byte across the full platform x command x device-kind x target matrix. --- .../capability-plugin-routing-parity.test.ts | 227 ++++++++++++++++++ src/core/capabilities.ts | 42 ++-- 2 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 src/core/__tests__/capability-plugin-routing-parity.test.ts diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts new file mode 100644 index 0000000000..977742dec4 --- /dev/null +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -0,0 +1,227 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + DEVICE_TARGETS, + PLATFORMS, + type DeviceInfo, + type DeviceKind, + type DeviceTarget, +} from '../../kernel/device.ts'; +import { + ANDROID_EMULATOR, + ANDROID_TV_DEVICE, + IOS_DEVICE, + IOS_SIMULATOR, + LINUX_DEVICE, + MACOS_DEVICE, + TVOS_SIMULATOR, + WEB_DESKTOP_DEVICE, +} from '../../__tests__/test-utils/index.ts'; +import { + BASE_COMMAND_CAPABILITY_MATRIX, + isCommandSupportedOnDevice, + listCapabilityCommands, + unsupportedHintForDevice, + type CommandCapability, +} from '../capabilities.ts'; +import { deriveCapabilityForPlatform } from '../platform-descriptor/derive.ts'; +import { platformDescriptors } from '../platform-descriptor/registry.ts'; +import { getPlugin } from '../platform-plugin/plugin.ts'; +import { registerBuiltinPlatformPlugins } from '../platform-plugin/register-builtins.ts'; + +// Phase 3 step (b) parity gate. Two independent oracles pin that the migration is +// byte-for-byte behaviorless: +// (b.1) the platform -> capability-bucket selection in `isCommandSupportedOnDevice` +// now flows through the PlatformPlugin registry instead of the +// `platformDescriptors` fold. `deriveCapabilityForPlatform(platformDescriptors, +// ...)` is kept here as the BEFORE-derivation oracle (the production fold was +// deleted), so a plugin-vs-descriptor disagreement fails this test. +// (b.2) the per-command `supports()` / `unsupportedHint()` closures stay VERBATIM on +// the command-descriptor facet (they cannot move to the plugin's per-FAMILY +// `capability.supportsByDefault` without flattening their per-command shape — +// perfect-shape §7). Independent verbatim copies below pin that the closures, +// as they flow through `deriveCapabilityMatrix` into admission, are unchanged +// across the full {platform x command x device-kind x target} matrix. + +registerBuiltinPlatformPlugins(); + +// --- the exhaustive synthetic device matrix (every platform x kind x target) --- +const DEVICE_KINDS_ALL: DeviceKind[] = ['simulator', 'emulator', 'device']; +const DEVICE_TARGETS_ALL: (DeviceTarget | undefined)[] = [undefined, ...DEVICE_TARGETS]; + +function buildDeviceMatrix(): DeviceInfo[] { + const devices: DeviceInfo[] = []; + for (const platform of PLATFORMS) { + for (const kind of DEVICE_KINDS_ALL) { + for (const target of DEVICE_TARGETS_ALL) { + devices.push({ + platform, + id: `${platform}-${kind}-${target ?? 'none'}`, + name: `${platform} ${kind} ${target ?? 'none'}`, + kind, + ...(target ? { target } : {}), + booted: true, + }); + } + } + } + return devices; +} + +// The hand-authored fixtures (reused per the plan) plus the exhaustive synthetic +// cross-product, so the real discovery shapes AND every off-nominal combination +// (e.g. a linux simulator, a macOS emulator) are pinned. +const SAMPLE_DEVICES: DeviceInfo[] = [ + ANDROID_EMULATOR, + ANDROID_TV_DEVICE, + IOS_DEVICE, + IOS_SIMULATOR, + LINUX_DEVICE, + MACOS_DEVICE, + TVOS_SIMULATOR, + WEB_DESKTOP_DEVICE, + ...buildDeviceMatrix(), +]; + +// --------------------------------------------------------------------------- +// (b.2) Independent VERBATIM copies of the per-command supports()/unsupportedHint() +// closures (src/core/command-descriptor/registry.ts). Kept BYTE-FOR-BYTE in sync by +// hand so this oracle stays INDEPENDENT of the descriptor it pins (mirrors the +// `selectCapabilityByHandSwitch` copy in platform-descriptor/__tests__/parity.test.ts). +// --------------------------------------------------------------------------- +const isNotMacOs = (device: DeviceInfo): boolean => device.platform !== 'macos'; +const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => + device.platform === 'macos' || device.kind === 'simulator'; +const isIosMobileSimulator = (device: DeviceInfo): boolean => + device.platform === 'ios' && device.kind === 'simulator' && device.target !== 'tv'; +const supportsSynthesisGesture = (device: DeviceInfo): boolean => + device.platform === 'android' || isIosMobileSimulator(device); +const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean => + device.platform === 'android' || (device.platform === 'ios' && device.target !== 'tv'); +const synthesisGestureUnsupportedHint = (device: DeviceInfo): string | undefined => { + if (device.platform === 'macos') + return 'macOS automation has no multi-touch input — this gesture is supported on Android and the iOS simulator only.'; + if (device.platform === 'ios' && device.target === 'tv') + return 'tvOS has no touch input — this gesture is supported on Android and the iOS simulator only.'; + if (device.platform === 'ios' && device.kind === 'device') + return 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.'; + return undefined; +}; + +// Which commands carry which supports()/unsupportedHint() closure today. The +// end-to-end assertions cross-check this map against production: a command that +// gains/loses a closure (or whose closure body changes) breaks parity. +const SUPPORTS_REF: Record boolean> = { + boot: isNotMacOs, + install: isNotMacOs, + reinstall: isNotMacOs, + 'install-from-source': isNotMacOs, + push: isNotMacOs, + home: isNotMacOs, + 'app-switcher': isNotMacOs, + clipboard: (device) => + device.platform === 'android' || + device.platform === 'linux' || + device.platform === 'macos' || + device.kind === 'simulator', + keyboard: supportsAndroidOrIosNonTv, + rotate: supportsAndroidOrIosNonTv, + alert: (device) => device.platform === 'android' || isMacOsOrAppleSimulator(device), + settings: (device) => + device.platform === 'android' || device.platform === 'macos' || device.kind === 'simulator', + pinch: supportsSynthesisGesture, + 'rotate-gesture': supportsSynthesisGesture, + 'transform-gesture': supportsSynthesisGesture, +}; +const HINT_REF: Record string | undefined> = { + pinch: synthesisGestureUnsupportedHint, + 'rotate-gesture': synthesisGestureUnsupportedHint, + 'transform-gesture': synthesisGestureUnsupportedHint, +}; + +// Independent reference for `isCommandSupportedOnDevice` over NON-WEB platforms, +// reproducing the BEFORE pipeline exactly: descriptor-fold bucket selection (b.1 +// oracle) + the verbatim supports closure (b.2 oracle) + the kind check. For a +// non-web platform the augmented matrix equals BASE (the web augmentation only adds +// a `web` key), so BASE is the faithful capability source here. +function isSupportedReference(command: string, device: DeviceInfo): boolean { + const capability: CommandCapability | undefined = BASE_COMMAND_CAPABILITY_MATRIX[command]; + if (!capability) return true; + const byPlatform = deriveCapabilityForPlatform(platformDescriptors, capability, device.platform); + if (!byPlatform) return false; + const supports = SUPPORTS_REF[command]; + if (supports && !supports(device)) return false; + const kind = (device.kind ?? 'unknown') as keyof NonNullable; + return byPlatform[kind] === true; +} + +test('(b.1) plugin-bucket selection is byte-identical to the platformDescriptors fold', () => { + // Object identities per bucket so a wrong-bucket selection fails ===, plus a + // web-bearing shape (BASE never carries a `web` key) so the `web` bucket route is + // exercised with a defined value, and a sparse shape for undefined propagation. + const shapes: CommandCapability[] = [ + ...Object.values(BASE_COMMAND_CAPABILITY_MATRIX), + { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, + linux: { device: true }, + web: { device: true }, + }, + { apple: { simulator: true } }, + ]; + for (const capability of shapes) { + for (const platform of PLATFORMS) { + assert.deepEqual( + capability[getPlugin(platform).capability.bucket], + deriveCapabilityForPlatform(platformDescriptors, capability, platform), + `bucket selection for ${platform}`, + ); + } + } +}); + +test('(b.1) isCommandSupportedOnDevice is unchanged across the command x device matrix', () => { + const commands = listCapabilityCommands(); + for (const command of commands) { + for (const device of SAMPLE_DEVICES) { + // BASE lacks the `web` augmentation, so the descriptor-fold reference is only + // faithful off the web platform; the web bucket route is pinned separately by + // the (b.1) bucket-selection test above and the web column of capabilities.test.ts. + if (device.platform === 'web') continue; + assert.equal( + isCommandSupportedOnDevice(command, device), + isSupportedReference(command, device), + `${command} on ${device.id}`, + ); + } + } +}); + +test('(b.2) unsupportedHint closures are verbatim across the full device matrix', () => { + const commands = listCapabilityCommands(); + for (const command of commands) { + const reference = HINT_REF[command]; + for (const device of SAMPLE_DEVICES) { + assert.equal( + unsupportedHintForDevice(command, device), + reference ? reference(device) : undefined, + `${command} hint on ${device.id}`, + ); + } + } +}); + +test('(b.2) every command carrying a supports closure is covered by the reference map', () => { + // Guards the SUPPORTS_REF/HINT_REF oracle against silently missing a closure: a + // command whose admission depends on a supports gate must appear in SUPPORTS_REF, + // and every hint-bearing command must appear in HINT_REF. + for (const command of listCapabilityCommands()) { + const capability = BASE_COMMAND_CAPABILITY_MATRIX[command]; + if (capability?.supports) { + assert.ok(SUPPORTS_REF[command], `${command} supports closure present in reference map`); + } + if (capability?.unsupportedHint) { + assert.ok(HINT_REF[command], `${command} unsupportedHint closure present in reference map`); + } + } +}); diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 8981622f14..d7cc15d49b 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -1,9 +1,17 @@ import { deriveCapabilityMatrix } from './command-descriptor/derive.ts'; import { commandDescriptors } from './command-descriptor/registry.ts'; -import { deriveCapabilityForPlatform } from './platform-descriptor/derive.ts'; -import { platformDescriptors } from './platform-descriptor/registry.ts'; +import { tryGetPlugin } from './platform-plugin/plugin.ts'; +import { registerBuiltinPlatformPlugins } from './platform-plugin/register-builtins.ts'; import type { DeviceInfo } from '../kernel/device.ts'; +// Populate the PlatformPlugin registry once at module load (idempotent; registers +// only lazy closures, so no leaf code is imported and CLI cold-start is unaffected +// — mirrors the same call in `core/interactors.ts`). `isCommandSupportedOnDevice` +// reads each platform's capability bucket from this registry, and the admission +// path reaches it (e.g. `daemon/handlers/response.ts`) without necessarily having +// loaded `core/interactors.ts` first, so the registry must be populated here. +registerBuiltinPlatformPlugins(); + type KindMatrix = { simulator?: boolean; device?: boolean; @@ -69,25 +77,23 @@ function addWebCommandCapabilities( return result; } -// Platform -> capability-bucket selection, folded from the additive -// platform-descriptor registry (ADR-0009, Phase 3 step 1). The hand-authored -// switch was deleted after `platform-descriptor/__tests__/parity.test.ts` proved -// deriveCapabilityForPlatform is byte-equal to it across all five platforms. The -// registry's compile-time totality keeps the prior safety: adding a new Platform -// without a descriptor row is a compile error, so it can no longer silently -// inherit web's capability matrix. The registry only type-imports CommandCapability -// from here, so this value-level dependency does not form a runtime cycle. -function selectCapabilityForPlatform( - capability: CommandCapability, - platform: DeviceInfo['platform'], -): KindMatrix | undefined { - return deriveCapabilityForPlatform(platformDescriptors, capability, platform); -} - export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean { const capability = COMMAND_CAPABILITY_MATRIX[command]; if (!capability) return true; - const byPlatform = selectCapabilityForPlatform(capability, device.platform); + // Platform -> capability-bucket selection now flows through the single + // PlatformPlugin registry (ADR-0009, Phase 3 step b.1): the bucket a leaf + // platform reads from a CommandCapability is the owning plugin's + // `capability.bucket`. This replaces the former `selectCapabilityForPlatform` + // fold over `platformDescriptors`; the plugin bucket is proven byte-for-byte + // equal to that derivation by `platform-plugin/__tests__/parity.test.ts`, and + // `__tests__/capability-plugin-routing-parity.test.ts` pins that this swap leaves + // `isCommandSupportedOnDevice` unchanged across the full command x device matrix. + // `tryGetPlugin` returns undefined only for an unregistered platform — the same + // "no bucket -> unsupported" fall-through the fold produced for a platform with + // no capability family (perfect-shape §5.1's `if (!plugin) return false`). + const plugin = tryGetPlugin(device.platform); + if (!plugin) return false; + const byPlatform = capability[plugin.capability.bucket]; if (!byPlatform) return false; if (capability.supports && !capability.supports(device)) return false; const kind = (device.kind ?? 'unknown') as keyof KindMatrix;