diff --git a/src/batch-policy.ts b/src/batch-policy.ts index 651c892de..3489d512c 100644 --- a/src/batch-policy.ts +++ b/src/batch-policy.ts @@ -1,4 +1,3 @@ -import { PUBLIC_COMMANDS } from './command-catalog.ts'; import { deriveStructuredBatchCommandNames } from './core/command-descriptor/derive.ts'; import { commandDescriptors } from './core/command-descriptor/registry.ts'; import { AppError } from './utils/errors.ts'; @@ -6,67 +5,27 @@ import { AppError } from './utils/errors.ts'; /** * The exact set of command names exposed through `batch`, as a narrow union. * - * This type is kept HAND-AUTHORED on purpose (ADR-0008, Phase 1 step 4): the - * runtime allowlist below is now derived from the command-descriptor registry, - * but the registry types each `name` as `string`, so deriving the value yields - * `string[]`. Re-deriving the type from that value would WIDEN this union to - * `string` and silently widen its downstream consumers — most notably - * `BatchCommandName` (re-exported from `command-surface.ts`) and the - * `satisfies readonly DaemonCommandName[]` guard in `commands/batch/projection.ts`. - * Keeping the union spelled out preserves those compile-time contracts. The - * derived runtime value is proven to match this union, member-for-member, by - * `core/command-descriptor/__tests__/parity.test.ts`. + * DERIVED from the command-descriptor registry (ADR-0008, Phase 1 step 7): the + * registry is now `as const` (#910), so each entry keeps its literal `name` and + * literal `batchable`. Extracting the entries whose `batchable` is `true` and + * indexing their `name` reconstructs this union from the same single source the + * runtime allowlist below is built from — no hand-maintained list to drift. The + * downstream contracts (`BatchCommandName`, re-exported from `command-surface.ts`, + * and the `satisfies readonly DaemonCommandName[]` guard in + * `commands/batch/projection.ts`) are still enforced by `tsc`. */ -export type StructuredBatchCommandName = - | (typeof PUBLIC_COMMANDS)['devices'] - | (typeof PUBLIC_COMMANDS)['boot'] - | (typeof PUBLIC_COMMANDS)['shutdown'] - | (typeof PUBLIC_COMMANDS)['apps'] - | (typeof PUBLIC_COMMANDS)['open'] - | (typeof PUBLIC_COMMANDS)['close'] - | (typeof PUBLIC_COMMANDS)['install'] - | (typeof PUBLIC_COMMANDS)['reinstall'] - | (typeof PUBLIC_COMMANDS)['installFromSource'] - | (typeof PUBLIC_COMMANDS)['push'] - | (typeof PUBLIC_COMMANDS)['triggerAppEvent'] - | (typeof PUBLIC_COMMANDS)['snapshot'] - | (typeof PUBLIC_COMMANDS)['screenshot'] - | (typeof PUBLIC_COMMANDS)['diff'] - | (typeof PUBLIC_COMMANDS)['wait'] - | (typeof PUBLIC_COMMANDS)['alert'] - | (typeof PUBLIC_COMMANDS)['settings'] - | (typeof PUBLIC_COMMANDS)['click'] - | (typeof PUBLIC_COMMANDS)['press'] - | (typeof PUBLIC_COMMANDS)['longPress'] - | (typeof PUBLIC_COMMANDS)['swipe'] - | (typeof PUBLIC_COMMANDS)['focus'] - | (typeof PUBLIC_COMMANDS)['type'] - | (typeof PUBLIC_COMMANDS)['fill'] - | (typeof PUBLIC_COMMANDS)['scroll'] - | (typeof PUBLIC_COMMANDS)['get'] - | (typeof PUBLIC_COMMANDS)['gesture'] - | (typeof PUBLIC_COMMANDS)['is'] - | (typeof PUBLIC_COMMANDS)['find'] - | (typeof PUBLIC_COMMANDS)['perf'] - | (typeof PUBLIC_COMMANDS)['logs'] - | (typeof PUBLIC_COMMANDS)['network'] - | (typeof PUBLIC_COMMANDS)['record'] - | (typeof PUBLIC_COMMANDS)['trace'] - | (typeof PUBLIC_COMMANDS)['test'] - | (typeof PUBLIC_COMMANDS)['appState'] - | (typeof PUBLIC_COMMANDS)['back'] - | (typeof PUBLIC_COMMANDS)['home'] - | (typeof PUBLIC_COMMANDS)['rotate'] - | (typeof PUBLIC_COMMANDS)['appSwitcher'] - | (typeof PUBLIC_COMMANDS)['keyboard'] - | (typeof PUBLIC_COMMANDS)['clipboard'] - | (typeof PUBLIC_COMMANDS)['reactNative']; +export type StructuredBatchCommandName = Extract< + (typeof commandDescriptors)[number], + { batchable: true } +>['name']; /** * The structured-batch allowlist, BUILT from the command-descriptor registry - * (the `batchable` flag) rather than hand-maintained. The derived value is a + * (the `batchable` flag) rather than hand-maintained. {@link deriveStructuredBatchCommandNames} + * folds over the registry as `readonly CommandDescriptor[]`, so it returns * `string[]`; the cast re-applies the narrow {@link StructuredBatchCommandName} - * union, whose membership equality with this value is asserted by the parity test. + * union (which derives from the same `batchable: true` entries). The parity test + * guards the wiring (the exported value equals the derived fold). */ export const STRUCTURED_BATCH_COMMAND_NAMES: readonly StructuredBatchCommandName[] = deriveStructuredBatchCommandNames(commandDescriptors) as readonly StructuredBatchCommandName[]; diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 101f86d05..cf05288d1 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -1,9 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { - STRUCTURED_BATCH_COMMAND_NAMES, - type StructuredBatchCommandName, -} from '../../../batch-policy.ts'; +import { STRUCTURED_BATCH_COMMAND_NAMES } from '../../../batch-policy.ts'; import { PUBLIC_COMMANDS } from '../../../command-catalog.ts'; import { BASE_COMMAND_CAPABILITY_MATRIX } from '../../capabilities.ts'; import { @@ -132,56 +129,6 @@ test('capability matrix holds its admission invariants', () => { } }); -// Exhaustive map over the StructuredBatchCommandName union. The compiler errors -// if a union member is missing a key OR a non-member key is added, so its keys -// ARE the union at type level — giving us a runtime handle on the (otherwise -// hand-authored) union to compare against the derived allowlist. -const STRUCTURED_BATCH_UNION_MEMBERS: Record = { - [PUBLIC_COMMANDS.devices]: true, - [PUBLIC_COMMANDS.boot]: true, - [PUBLIC_COMMANDS.shutdown]: true, - [PUBLIC_COMMANDS.apps]: true, - [PUBLIC_COMMANDS.open]: true, - [PUBLIC_COMMANDS.close]: true, - [PUBLIC_COMMANDS.install]: true, - [PUBLIC_COMMANDS.reinstall]: true, - [PUBLIC_COMMANDS.installFromSource]: true, - [PUBLIC_COMMANDS.push]: true, - [PUBLIC_COMMANDS.triggerAppEvent]: true, - [PUBLIC_COMMANDS.snapshot]: true, - [PUBLIC_COMMANDS.screenshot]: true, - [PUBLIC_COMMANDS.diff]: true, - [PUBLIC_COMMANDS.wait]: true, - [PUBLIC_COMMANDS.alert]: true, - [PUBLIC_COMMANDS.settings]: true, - [PUBLIC_COMMANDS.click]: true, - [PUBLIC_COMMANDS.press]: true, - [PUBLIC_COMMANDS.longPress]: true, - [PUBLIC_COMMANDS.swipe]: true, - [PUBLIC_COMMANDS.focus]: true, - [PUBLIC_COMMANDS.type]: true, - [PUBLIC_COMMANDS.fill]: true, - [PUBLIC_COMMANDS.scroll]: true, - [PUBLIC_COMMANDS.get]: true, - [PUBLIC_COMMANDS.gesture]: true, - [PUBLIC_COMMANDS.is]: true, - [PUBLIC_COMMANDS.find]: true, - [PUBLIC_COMMANDS.perf]: true, - [PUBLIC_COMMANDS.logs]: true, - [PUBLIC_COMMANDS.network]: true, - [PUBLIC_COMMANDS.record]: true, - [PUBLIC_COMMANDS.trace]: true, - [PUBLIC_COMMANDS.test]: true, - [PUBLIC_COMMANDS.appState]: true, - [PUBLIC_COMMANDS.back]: true, - [PUBLIC_COMMANDS.home]: true, - [PUBLIC_COMMANDS.rotate]: true, - [PUBLIC_COMMANDS.appSwitcher]: true, - [PUBLIC_COMMANDS.keyboard]: true, - [PUBLIC_COMMANDS.clipboard]: true, - [PUBLIC_COMMANDS.reactNative]: true, -}; - // Control-plane / non-batchable commands that must never enter the allowlist. const NON_BATCHABLE_COMMANDS = [ PUBLIC_COMMANDS.batch, @@ -195,29 +142,23 @@ const NON_BATCHABLE_COMMANDS = [ 'transform-gesture', ]; -test('structured-batch allowlist is built from descriptors and matches the kept union', () => { +test('structured-batch allowlist is built from descriptors', () => { const derived = deriveStructuredBatchCommandNames(commandDescriptors); // No duplicates in the derived allowlist. assert.equal(new Set(derived).size, derived.length, 'no duplicate batchable names'); // The exported allowlist is now BUILT from these derived descriptors, so it is - // the derived list (order included) — guards the wiring, not a tautology of - // membership. + // the derived list (order included) — guards the wiring. (The narrow + // StructuredBatchCommandName union now DERIVES from the same `batchable: true` + // entries via Extract, so an exhaustive-union membership check would be a + // tautology; `tsc` enforces the type, this guards the value.) assert.deepEqual( [...STRUCTURED_BATCH_COMMAND_NAMES], derived, 'exported allowlist is built from the descriptors', ); - // Membership equals the kept StructuredBatchCommandName union (proves the - // narrow type the consumers rely on did not drift from the runtime value). - assert.deepEqual( - [...new Set(derived)].sort(), - Object.keys(STRUCTURED_BATCH_UNION_MEMBERS).sort(), - 'derived membership equals the kept union', - ); - // Every batchable command is a real public command (no internal/control name leaks in). const publicCommands = new Set(Object.values(PUBLIC_COMMANDS)); for (const name of derived) {