diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4b4639015..0f6e5ac889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,7 @@ jobs: - name: Build and pack CLI run: | pnpm build + pnpm check:bundle-owner-files mkdir -p .tmp/node-compat npm pack --ignore-scripts --pack-destination .tmp/node-compat diff --git a/package.json b/package.json index 3ff854d061..5c2f800337 100644 --- a/package.json +++ b/package.json @@ -121,11 +121,12 @@ "check:layering:baseline": "node --experimental-strip-types scripts/layering/check.ts --update-baseline", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --baseline fallow-baselines/production-unused-exports.json --fail-on-issues", "check:production-exports:baseline": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --save-baseline fallow-baselines/production-unused-exports.json --summary", + "check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts", "check:quick": "pnpm lint && pnpm typecheck", "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check", "version": "node scripts/sync-mcp-metadata.mjs && git add server.json", - "check:tooling": "pnpm lint && pnpm typecheck && pnpm check:layering && pnpm check:production-exports && pnpm check:mcp-metadata && pnpm build", + "check:tooling": "pnpm lint && pnpm typecheck && pnpm check:layering && pnpm check:production-exports && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files", "check:unit": "pnpm test:unit && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm build:all && pnpm package:apple-runner:npm && pnpm package:android-snapshot-helper:npm && pnpm package:android-multitouch-helper:npm && pnpm package:android-ime-helper:npm", diff --git a/scripts/check-bundle-owner-files.ts b/scripts/check-bundle-owner-files.ts new file mode 100644 index 0000000000..09a366cbb2 --- /dev/null +++ b/scripts/check-bundle-owner-files.ts @@ -0,0 +1,41 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { COMMAND_OWNER_FILES } from '../src/core/command-descriptor/owner-files.ts'; +import { getDaemonRouteOwnerFiles } from '../src/daemon/route-owner-files.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '..'); +const distRoot = path.join(repoRoot, 'dist', 'src'); +const ownerPaths = new Set([ + ...Object.values(COMMAND_OWNER_FILES).flat(), + ...Object.values(getDaemonRouteOwnerFiles()), +]); +const forbiddenMetadata = new Set(['ownerFiles', ...ownerPaths]); + +const bundleFiles = walkFiles(distRoot).filter((file) => file.endsWith('.js')); +if (bundleFiles.length === 0) { + throw new Error('No dist/src JavaScript files found. Run `pnpm build` first.'); +} + +const leaks = bundleFiles.flatMap((file) => { + const content = fs.readFileSync(file, 'utf8'); + return [...forbiddenMetadata] + .filter((value) => content.includes(value)) + .map((value) => ({ file: path.relative(repoRoot, file), value })); +}); + +if (leaks.length > 0) { + const details = leaks.map(({ file, value }) => `- ${value} in ${file}`).join('\n'); + throw new Error(`Owner-file navigation metadata leaked into production bundles:\n${details}`); +} + +process.stdout.write( + `Verified the ownerFiles key and ${ownerPaths.size} owner-file paths are absent from ${bundleFiles.length} production bundles.\n`, +); + +function walkFiles(root: string): string[] { + if (!fs.existsSync(root)) return []; + return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(root, entry.name); + return entry.isDirectory() ? walkFiles(entryPath) : [entryPath]; + }); +} diff --git a/scripts/explain-command.ts b/scripts/explain-command.ts index 0e964b0238..973a573e5b 100644 --- a/scripts/explain-command.ts +++ b/scripts/explain-command.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { explainCommand, formatCommandExplanation } from '../src/commands/command-explain.ts'; -import { getDaemonRouteOwnerFiles } from '../src/daemon/request-handler-chain.ts'; +import { getDaemonRouteOwnerFiles } from '../src/daemon/route-owner-files.ts'; const repoRoot = path.resolve(import.meta.dirname, '..'); const args = process.argv.slice(2); diff --git a/src/commands/__tests__/command-explain.test.ts b/src/commands/__tests__/command-explain.test.ts index 7c22b7f028..84f4939908 100644 --- a/src/commands/__tests__/command-explain.test.ts +++ b/src/commands/__tests__/command-explain.test.ts @@ -3,7 +3,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { describe, expect, test } from 'vitest'; import { commandDescriptors } from '../../core/command-descriptor/registry.ts'; -import { getDaemonRouteOwnerFiles } from '../../daemon/request-handler-chain.ts'; +import { ownerFilesForCommand } from '../../core/command-descriptor/owner-files.ts'; +import { getDaemonRouteOwnerFiles } from '../../daemon/route-owner-files.ts'; import { explainCommand as explainCommandFromMetadata, formatCommandExplanation, @@ -165,7 +166,7 @@ describe('explainCommand table-driven coverage', () => { const result = explainCommand(descriptor.name, { fileExists }); expect(result.found).toBe(true); if (!result.found) continue; - for (const ownerFile of descriptor.ownerFiles) { + for (const ownerFile of ownerFilesForCommand(descriptor.name)) { expect(result.explanation.files).toContain(ownerFile); } for (const file of result.explanation.files) { @@ -174,6 +175,15 @@ describe('explainCommand table-driven coverage', () => { } }); + test('owner-file claims stay tooling-only: production descriptors do not carry them', () => { + for (const descriptor of commandDescriptors) { + expect( + Object.hasOwn(descriptor, 'ownerFiles'), + `${descriptor.name} still exposes ownerFiles on the runtime descriptor`, + ).toBe(false); + } + }); + test('every daemon explanation uses its production handler module owner', () => { for (const descriptor of commandDescriptors) { if (!('daemon' in descriptor) || !descriptor.daemon) continue; diff --git a/src/commands/command-explain.ts b/src/commands/command-explain.ts index 601c2a9b8e..9e60930b31 100644 --- a/src/commands/command-explain.ts +++ b/src/commands/command-explain.ts @@ -3,6 +3,7 @@ import { cliAliasesForCommand, normalizeCliCommandAlias } from '../cli-command-a import { buildCommandUsage } from '../utils/cli-usage.ts'; import type { DaemonCommandRoute } from '../daemon/daemon-command-registry.ts'; import { commandDescriptors, type Command } from '../core/command-descriptor/registry.ts'; +import { ownerFilesForCommand } from '../core/command-descriptor/owner-files.ts'; import type { CommandCapability } from '../core/capabilities.ts'; import type { CommandTimeoutPolicy } from '../core/command-descriptor/types.ts'; import { @@ -121,7 +122,7 @@ function buildCommandExplanation( ...(cliSchema ? { cli: describeCliSurface(descriptor.name, cliSchema) } : {}), files: commandFiles( descriptor.name, - descriptor.ownerFiles, + ownerFilesForCommand(descriptor.name), family?.name, 'daemon' in descriptor ? descriptor.daemon?.route : undefined, Boolean('capability' in descriptor && descriptor.capability), diff --git a/src/core/command-descriptor/__tests__/owner-files.test.ts b/src/core/command-descriptor/__tests__/owner-files.test.ts new file mode 100644 index 0000000000..e4e171f4f5 --- /dev/null +++ b/src/core/command-descriptor/__tests__/owner-files.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'vitest'; +import { commandDescriptors } from '../registry.ts'; +import { COMMAND_OWNER_FILES, ownerFilesForCommand } from '../owner-files.ts'; + +describe('command owner-file projection', () => { + test('covers exactly the registered commands (completeness, no extras)', () => { + const declared = Object.keys(COMMAND_OWNER_FILES).sort(); + const registered = commandDescriptors.map((descriptor) => descriptor.name).sort(); + expect(declared).toEqual(registered); + }); + + test('every command maps to a non-empty owner-file list', () => { + for (const descriptor of commandDescriptors) { + expect(ownerFilesForCommand(descriptor.name).length).toBeGreaterThan(0); + } + }); + + test('the projection is not reachable from the runtime descriptor objects', () => { + for (const descriptor of commandDescriptors) { + expect(Object.hasOwn(descriptor, 'ownerFiles')).toBe(false); + } + }); +}); diff --git a/src/core/command-descriptor/owner-files.ts b/src/core/command-descriptor/owner-files.ts new file mode 100644 index 0000000000..796acfac11 --- /dev/null +++ b/src/core/command-descriptor/owner-files.ts @@ -0,0 +1,48 @@ +import { RAW_COMMAND_DESCRIPTORS, type Command } from './registry.ts'; + +/** + * Development-only owner-file navigation claims for every command (ADR 0008 + * follow-up, https://github.com/callstack/agent-device/issues/1178). + * + * These paths point a reader at the module that owns each command's surface so + * `explain:command` can render "where does this live". They are pure tooling + * metadata: nothing in the daemon/CLI runtime reads them, so they were removed + * from the production {@link CommandDescriptor} objects (and therefore from the + * emitted bundles) and kept as a derived view on the source-of-truth registry. + * + * The key space is the descriptor-derived {@link Command} union, so deriving the + * projection from {@link RAW_COMMAND_DESCRIPTORS} makes a missing or misspelled + * command a type error and forbids owner claims for commands that do not exist. + * Only `command-explain.ts` and its tests import this module; the production + * import graph never reaches it, so the bundler drops it entirely. + */ +type OwnerFilesFromDescriptors< + T extends readonly { + readonly name: string; + readonly ownerFiles?: readonly [string, ...string[]]; + }[], +> = { + [K in keyof T & number as T[K]['name']]: NonNullable; +}; + +const buildOwnerFiles = < + const T extends readonly { + readonly name: string; + readonly ownerFiles?: readonly [string, ...string[]]; + }[], +>( + entries: T, +): OwnerFilesFromDescriptors => + Object.fromEntries( + entries.map((entry) => [entry.name, entry.ownerFiles]), + ) as OwnerFilesFromDescriptors; + +export const COMMAND_OWNER_FILES = buildOwnerFiles(RAW_COMMAND_DESCRIPTORS) satisfies Record< + Command, + readonly [string, ...string[]] +>; + +/** The owner-file claims for one command (development-only navigation metadata). */ +export function ownerFilesForCommand(command: Command): readonly [string, ...string[]] { + return COMMAND_OWNER_FILES[command]; +} diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 7944707395..4c74752d5c 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -17,6 +17,7 @@ import type { type RawCommandDescriptor = Omit & { mcpExposed?: boolean; + ownerFiles?: readonly [string, ...string[]]; }; type RawCommandCatalogGroup = T extends { catalog: { group: infer Group } } ? Group : never; @@ -199,11 +200,13 @@ function postActionObservation(command: string): PostActionObservationSupport { // array rather than recreating command-name sets. // --------------------------------------------------------------------------- -const RAW_COMMAND_DESCRIPTORS = [ +const ownerFilesEnabled = typeof __OWNER_FILES__ === 'undefined' || __OWNER_FILES__; + +export const RAW_COMMAND_DESCRIPTORS = [ // -- lease (route: lease) -- { name: 'lease_allocate', - ownerFiles: ['src/daemon/handlers/lease.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseAllocate' }, daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -211,7 +214,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'lease_heartbeat', - ownerFiles: ['src/daemon/handlers/lease.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseHeartbeat' }, daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -219,7 +222,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'lease_release', - ownerFiles: ['src/daemon/handlers/lease.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/lease.ts'] as const } : {}), catalog: { group: 'internal', key: 'leaseRelease' }, daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -227,7 +230,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'artifacts', - ownerFiles: ['src/commands/management/artifacts.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/artifacts.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'lease', ...ADMISSION_AND_LOCK_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -237,7 +240,9 @@ const RAW_COMMAND_DESCRIPTORS = [ // -- session (route: session) -- { name: 'session_list', - ownerFiles: ['src/daemon/handlers/session-inventory.ts'], + ...(ownerFilesEnabled + ? { ownerFiles: ['src/daemon/handlers/session-inventory.ts'] as const } + : {}), catalog: { group: 'internal', key: 'sessionList' }, daemon: { route: 'session', sessionKind: 'inventory', ...REQUEST_EXECUTION_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -245,7 +250,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'devices', - ownerFiles: ['src/commands/management/device.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', @@ -258,7 +263,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'capabilities', - ownerFiles: ['src/commands/management/device.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', @@ -272,7 +277,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'doctor', - ownerFiles: ['src/commands/management/doctor.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/doctor.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', @@ -286,7 +291,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'apps', - ownerFiles: ['src/commands/management/app.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', @@ -300,7 +305,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'boot', - ownerFiles: ['src/commands/management/device.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', sessionKind: 'state' }, capability: { @@ -313,7 +318,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'shutdown', - ownerFiles: ['src/commands/management/device.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/device.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', sessionKind: 'state' }, capability: { @@ -326,7 +331,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'appstate', - ownerFiles: ['src/commands/system/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appState' }, daemon: { route: 'session', sessionKind: 'state' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -334,7 +339,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'perf', - ownerFiles: ['src/commands/perf/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/perf/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, @@ -343,7 +348,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'logs', - ownerFiles: ['src/commands/observability/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, @@ -352,7 +357,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'events', - ownerFiles: ['src/commands/observability/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', @@ -365,7 +370,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'network', - ownerFiles: ['src/commands/observability/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', sessionKind: 'observability' }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, @@ -374,7 +379,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'audio', - ownerFiles: ['src/commands/observability/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/observability/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', sessionKind: 'observability' }, capability: { @@ -387,7 +392,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'replay', - ownerFiles: ['src/commands/replay/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/replay/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', @@ -400,7 +405,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'test', - ownerFiles: ['src/commands/replay/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/replay/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', @@ -414,7 +419,9 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'runtime', - ownerFiles: ['src/daemon/handlers/session-runtime-command.ts'], + ...(ownerFilesEnabled + ? { ownerFiles: ['src/daemon/handlers/session-runtime-command.ts'] as const } + : {}), catalog: { group: 'internal' }, daemon: { route: 'session' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -422,7 +429,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'clipboard', - ownerFiles: ['src/commands/system/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', replayScopedAction: true }, dispatch: {}, @@ -436,7 +443,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'keyboard', - ownerFiles: ['src/commands/system/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -450,7 +457,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'install', - ownerFiles: ['src/commands/management/install.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session' }, capability: APP_INSTALL_CAPABILITY, @@ -459,7 +466,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'reinstall', - ownerFiles: ['src/commands/management/install.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session' }, capability: APP_INSTALL_CAPABILITY, @@ -468,7 +475,9 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'install_source', - ownerFiles: ['src/daemon/handlers/install-source.ts'], + ...(ownerFilesEnabled + ? { ownerFiles: ['src/daemon/handlers/install-source.ts'] as const } + : {}), catalog: { group: 'internal', key: 'installSource' }, daemon: { route: 'session' }, timeoutPolicy: INSTALL_TIMEOUT_POLICY, @@ -476,7 +485,9 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'release_materialized_paths', - ownerFiles: ['src/daemon/handlers/install-source.ts'], + ...(ownerFilesEnabled + ? { ownerFiles: ['src/daemon/handlers/install-source.ts'] as const } + : {}), catalog: { group: 'internal', key: 'releaseMaterializedPaths' }, daemon: { route: 'session', ...REQUEST_EXECUTION_EXEMPT }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -484,7 +495,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'push', - ownerFiles: ['src/commands/management/push.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session' }, dispatch: {}, @@ -498,7 +509,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'trigger-app-event', - ownerFiles: ['src/commands/management/push.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/push.ts'] as const } : {}), catalog: { group: 'public', key: 'triggerAppEvent' }, daemon: { route: 'session' }, dispatch: {}, @@ -508,7 +519,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'open', - ownerFiles: ['src/commands/management/app.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', allowSessionlessDefaultDevice: allowAnyDeviceSessionless }, dispatch: {}, @@ -518,7 +529,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'prepare', - ownerFiles: ['src/commands/management/prepare.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/prepare.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session' }, // Runner warm-up builds are the longest fixed envelope; --timeout overrides. @@ -532,7 +543,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'batch', - ownerFiles: ['src/commands/batch/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/batch/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -540,7 +551,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'close', - ownerFiles: ['src/commands/management/app.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/app.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'session', allowInvalidRecording: true }, dispatch: {}, @@ -552,7 +563,7 @@ const RAW_COMMAND_DESCRIPTORS = [ // -- snapshot (route: snapshot) -- { name: 'snapshot', - ownerFiles: ['src/commands/capture/snapshot.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/snapshot.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'snapshot', replayScopedAction: true }, dispatch: {}, @@ -564,7 +575,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'diff', - ownerFiles: ['src/commands/capture/diff.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/diff.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'snapshot', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, @@ -573,7 +584,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'wait', - ownerFiles: ['src/commands/capture/wait.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/wait.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'snapshot', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, @@ -587,7 +598,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'alert', - ownerFiles: ['src/commands/capture/alert.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/alert.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'snapshot', replayScopedAction: true }, capability: { @@ -600,7 +611,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'settings', - ownerFiles: ['src/commands/capture/settings.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/settings.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'snapshot', replayScopedAction: true }, dispatch: {}, @@ -616,7 +627,7 @@ const RAW_COMMAND_DESCRIPTORS = [ // -- specialized routes -- { name: 'react-native', - ownerFiles: ['src/commands/react-native/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/react-native/index.ts'] as const } : {}), catalog: { group: 'public', key: 'reactNative' }, daemon: { route: 'reactNative', replayScopedAction: true }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_NONE }, @@ -625,7 +636,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'record', - ownerFiles: ['src/commands/recording/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'recordTrace', @@ -639,7 +650,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'trace', - ownerFiles: ['src/commands/recording/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/recording/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'recordTrace' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -647,7 +658,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'find', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'find', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, @@ -664,7 +675,7 @@ const RAW_COMMAND_DESCRIPTORS = [ // stuck Apple runner work. { name: 'click', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, capability: { apple: APPLE_SIM_AND_DEVICE, android: ANDROID_ALL, linux: LINUX_DEVICE }, @@ -675,7 +686,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'fill', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -687,7 +698,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'longpress', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public', key: 'longPress' }, daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -698,7 +709,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'press', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -710,7 +721,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'type', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'interaction', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -720,7 +731,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'get', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'interaction', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, @@ -729,7 +740,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'read', - ownerFiles: ['src/daemon/handlers/interaction.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/daemon/handlers/interaction.ts'] as const } : {}), catalog: { group: 'dispatch-alias' }, dispatch: {}, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -737,7 +748,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'is', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'interaction', replayScopedAction: true }, capability: ALL_DEVICE_COMMAND_CAPABILITY, @@ -748,7 +759,7 @@ const RAW_COMMAND_DESCRIPTORS = [ // -- generic (route: generic) -- { name: 'back', - ownerFiles: ['src/commands/system/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -758,7 +769,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'gesture', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -766,7 +777,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'home', - ownerFiles: ['src/commands/system/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -780,7 +791,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'tv-remote', - ownerFiles: ['src/commands/system/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'tvRemote' }, daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -794,7 +805,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'rotate', - ownerFiles: ['src/commands/system/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -808,7 +819,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'scroll', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -818,7 +829,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'swipe', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -828,7 +839,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'swipe-preset', - ownerFiles: ['src/core/dispatch.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/core/dispatch.ts'] as const } : {}), catalog: { group: 'dispatch-alias' }, dispatch: {}, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -836,7 +847,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'pinch', - ownerFiles: ['src/core/dispatch.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/core/dispatch.ts'] as const } : {}), catalog: { group: 'dispatch-alias' }, daemon: { route: 'generic', replayScopedAction: true, androidBlockingDialogGuard: true }, dispatch: {}, @@ -850,7 +861,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'focus', - ownerFiles: ['src/commands/interaction/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/interaction/index.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', androidBlockingDialogGuard: true }, dispatch: {}, @@ -860,7 +871,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'screenshot', - ownerFiles: ['src/commands/capture/screenshot.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/capture/screenshot.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', replayScopedAction: true }, dispatch: {}, @@ -870,7 +881,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'viewport', - ownerFiles: ['src/commands/management/viewport.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/viewport.ts'] as const } : {}), catalog: { group: 'public' }, daemon: { route: 'generic', replayScopedAction: true }, dispatch: {}, @@ -880,7 +891,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'pan', - ownerFiles: ['src/core/dispatch.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/core/dispatch.ts'] as const } : {}), catalog: { group: 'dispatch-alias' }, daemon: { route: 'generic', androidBlockingDialogGuard: true }, dispatch: {}, @@ -890,7 +901,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'fling', - ownerFiles: ['src/core/dispatch.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/core/dispatch.ts'] as const } : {}), catalog: { group: 'dispatch-alias' }, daemon: { route: 'generic', androidBlockingDialogGuard: true }, dispatch: {}, @@ -900,7 +911,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'rotate-gesture', - ownerFiles: ['src/core/dispatch.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/core/dispatch.ts'] as const } : {}), catalog: { group: 'dispatch-alias' }, daemon: { route: 'generic', androidBlockingDialogGuard: true }, dispatch: {}, @@ -914,7 +925,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'transform-gesture', - ownerFiles: ['src/core/dispatch.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/core/dispatch.ts'] as const } : {}), catalog: { group: 'dispatch-alias' }, daemon: { route: 'generic', androidBlockingDialogGuard: true }, dispatch: {}, @@ -930,7 +941,7 @@ const RAW_COMMAND_DESCRIPTORS = [ // -- capability/batch-only commands (no daemon route) -- { name: 'app-switcher', - ownerFiles: ['src/commands/system/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), catalog: { group: 'public', key: 'appSwitcher' }, dispatch: {}, capability: { @@ -943,7 +954,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'install-from-source', - ownerFiles: ['src/commands/management/install.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/install.ts'] as const } : {}), catalog: { group: 'public', key: 'installFromSource' }, capability: APP_INSTALL_CAPABILITY, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, @@ -953,28 +964,28 @@ const RAW_COMMAND_DESCRIPTORS = [ // -- local client-backed CLI/MCP commands (no daemon route/capability) -- { name: 'debug', - ownerFiles: ['src/commands/debugging/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/debugging/index.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, { name: 'metro', - ownerFiles: ['src/commands/metro/index.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/metro/index.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, { name: 'session', - ownerFiles: ['src/commands/management/session.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/management/session.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, }, { name: 'cdp', - ownerFiles: ['src/cli/commands/agent-cdp.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/agent-cdp.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -982,7 +993,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'auth', - ownerFiles: ['src/cli/commands/auth.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/auth.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -990,7 +1001,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'connect', - ownerFiles: ['src/cli/commands/connection.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/connection.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -998,7 +1009,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'connection', - ownerFiles: ['src/cli/commands/connection.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/connection.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -1006,7 +1017,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'disconnect', - ownerFiles: ['src/cli/commands/connection.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/connection.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -1014,7 +1025,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'mcp', - ownerFiles: ['src/bin.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/bin.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -1022,7 +1033,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'proxy', - ownerFiles: ['src/cli/commands/proxy.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/proxy.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -1030,7 +1041,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'react-devtools', - ownerFiles: ['src/cli/commands/react-devtools.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/react-devtools.ts'] as const } : {}), catalog: { group: 'local-cli', key: 'reactDevtools' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -1038,7 +1049,7 @@ const RAW_COMMAND_DESCRIPTORS = [ }, { name: 'web', - ownerFiles: ['src/cli/commands/web.ts'], + ...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/web.ts'] as const } : {}), catalog: { group: 'local-cli' }, timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: false, @@ -1046,6 +1057,16 @@ const RAW_COMMAND_DESCRIPTORS = [ }, ] as const satisfies readonly RawCommandDescriptor[]; +/** + * Compile-time owner-claim totality. `keyof` on a union contains only keys + * shared by every member, so removing `ownerFiles` from any raw descriptor + * makes this resolve to `false` and fail the `AssertTrue` constraint. + */ +type AssertTrue = T; +export type CommandOwnerFileClaimsAreComplete = AssertTrue< + 'ownerFiles' extends keyof (typeof RAW_COMMAND_DESCRIPTORS)[number] ? true : false +>; + const CLI_CATALOG_GROUPS = new Set(['public', 'local-cli']); const CLI_COMMAND_NAMES = new Set( @@ -1062,10 +1083,20 @@ const CLI_COMMAND_NAMES = new Set( * 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 = RAW_COMMAND_DESCRIPTORS.map((descriptor) => ({ - ...descriptor, - mcpExposed: resolveMcpExposure(descriptor), -})) satisfies readonly CommandDescriptor[]; +export const commandDescriptors = RAW_COMMAND_DESCRIPTORS.map((descriptor) => { + if (!ownerFilesEnabled) { + return { + ...descriptor, + mcpExposed: resolveMcpExposure(descriptor), + }; + } + + const { ownerFiles: _, ...runtimeDescriptor } = descriptor; + return { + ...runtimeDescriptor, + mcpExposed: resolveMcpExposure(descriptor), + }; +}) satisfies readonly CommandDescriptor[]; /** The literal union of every registered command name. */ export type Command = (typeof commandDescriptors)[number]['name']; diff --git a/src/core/command-descriptor/types.ts b/src/core/command-descriptor/types.ts index ea4e326732..b934c222a4 100644 --- a/src/core/command-descriptor/types.ts +++ b/src/core/command-descriptor/types.ts @@ -129,7 +129,6 @@ export type CommandDispatchFacet = { */ export type CommandDescriptor = { name: string; - ownerFiles: readonly [string, ...string[]]; daemon?: DaemonCommandTraits; capability?: CommandCapability; batchable: boolean; diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index 286601d812..7103a2af53 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -3,7 +3,8 @@ import fs from 'node:fs'; import { test } from 'vitest'; import { INTERNAL_COMMANDS } from '../../command-catalog.ts'; import { LeaseRegistry } from '../lease-registry.ts'; -import { getDaemonRouteOwnerFiles, runRequestHandlerChain } from '../request-handler-chain.ts'; +import { runRequestHandlerChain } from '../request-handler-chain.ts'; +import { getDaemonRouteOwnerFiles } from '../route-owner-files.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { makeIosSession } from '../../__tests__/test-utils/index.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; @@ -36,28 +37,29 @@ function makeChainParams(req: DaemonRequest) { test('route owner files match the production module loaders', () => { const source = fs.readFileSync(new URL('../request-handler-chain.ts', import.meta.url), 'utf8'); const definitions = [ - ...source.matchAll( - /(\w+): defineDaemonRoute\(\{\s+ownerFile: '([^']+)',\s+load: \(\) => import\('([^']+)'\),/g, - ), + ...source.matchAll(/(\w+): defineDaemonRoute\(\{\s+load: \(\) => import\('([^']+)'\),/g), ]; const ownerFiles = getDaemonRouteOwnerFiles(); const genericModulePath = /import \* as genericRequestHandlerModule from '([^']+)'/.exec( source, )?.[1]; - const genericOwnerFile = - /generic: defineDaemonRoute\(\{\s+ownerFile: '([^']+)',\s+load: async \(\) => genericRequestHandlerModule,/.exec( + const genericRouteMatches = + /generic: defineDaemonRoute\(\{\s+load: async \(\) => genericRequestHandlerModule,/.test( source, - )?.[1]; + ); assert.equal(definitions.length + 1, Object.keys(ownerFiles).length); - for (const [, route, ownerFile, modulePath] of definitions) { - assert.ok(route && ownerFile && modulePath); - assert.equal(ownerFile, `src/daemon/${modulePath.slice(2)}`); - assert.equal(ownerFiles[route as keyof typeof ownerFiles], ownerFile); + for (const [, route, modulePath] of definitions) { + assert.ok(route && modulePath); + assert.equal(ownerFiles[route as keyof typeof ownerFiles], `src/daemon/${modulePath.slice(2)}`); } - assert.ok(genericModulePath && genericOwnerFile); - assert.equal(genericOwnerFile, `src/daemon/${genericModulePath.slice(2)}`); - assert.equal(ownerFiles.generic, genericOwnerFile); + assert.ok(genericModulePath && genericRouteMatches); + assert.equal(ownerFiles.generic, `src/daemon/${genericModulePath.slice(2)}`); + + assert.ok( + !/ownerFile/.test(source), + 'owner-file paths are tooling-only: keep them in route-owner-files.ts, not the production chain module', + ); }); test('request handler chain routes trace commands to the record-trace family', async () => { diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index 65dc2ce4d3..dc0bba2864 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -30,42 +30,34 @@ type RequestHandlerChainParams = { const DAEMON_ROUTE_HANDLERS = { lease: defineDaemonRoute({ - ownerFile: 'src/daemon/handlers/lease.ts', load: () => import('./handlers/lease.ts'), run: runLeaseHandler, }), session: defineDaemonRoute({ - ownerFile: 'src/daemon/handlers/session.ts', load: () => import('./handlers/session.ts'), run: runSessionHandler, }), snapshot: defineDaemonRoute({ - ownerFile: 'src/daemon/handlers/snapshot.ts', load: () => import('./handlers/snapshot.ts'), run: runSnapshotHandler, }), reactNative: defineDaemonRoute({ - ownerFile: 'src/daemon/handlers/react-native.ts', load: () => import('./handlers/react-native.ts'), run: runReactNativeHandler, }), recordTrace: defineDaemonRoute({ - ownerFile: 'src/daemon/handlers/record-trace.ts', load: () => import('./handlers/record-trace.ts'), run: runRecordTraceHandler, }), find: defineDaemonRoute({ - ownerFile: 'src/daemon/handlers/find.ts', load: () => import('./handlers/find.ts'), run: runFindHandler, }), interaction: defineDaemonRoute({ - ownerFile: 'src/daemon/handlers/interaction.ts', load: () => import('./handlers/interaction.ts'), run: runInteractionHandler, }), generic: defineDaemonRoute({ - ownerFile: 'src/daemon/request-generic-dispatch.ts', load: async () => genericRequestHandlerModule, run: async () => null, }), @@ -80,12 +72,6 @@ export async function runRequestHandlerChain( return await DAEMON_ROUTE_HANDLERS[route].run(params); } -export function getDaemonRouteOwnerFiles(): Record { - const routes = Object.keys(DAEMON_ROUTE_HANDLERS) as DaemonCommandRoute[]; - const entries = routes.map((route) => [route, DAEMON_ROUTE_HANDLERS[route].ownerFile] as const); - return Object.fromEntries(entries) as Record; -} - export async function loadGenericRequestHandlerModule(): Promise< typeof import('./request-generic-dispatch.ts') > { @@ -215,13 +201,11 @@ async function runInteractionHandler( } function defineDaemonRoute(definition: { - ownerFile: string; load: () => Promise; run: (module: TModule, params: RequestHandlerChainParams) => Promise; }) { const loadModule = lazyImport(definition.load); return { - ownerFile: definition.ownerFile, loadModule, run: async (params: RequestHandlerChainParams) => await definition.run(await loadModule(), params), diff --git a/src/daemon/route-owner-files.ts b/src/daemon/route-owner-files.ts new file mode 100644 index 0000000000..bd39a3bc2c --- /dev/null +++ b/src/daemon/route-owner-files.ts @@ -0,0 +1,32 @@ +import type { DaemonCommandRoute } from './request-handler-chain.ts'; + +/** + * Development-only owner-file navigation claims for each daemon route (ADR 0008 + * follow-up, https://github.com/callstack/agent-device/issues/1178). + * + * Each route's runtime binding is its lazy `load` loader in + * {@link DAEMON_ROUTE_HANDLERS}; the owner-file path is pure tooling metadata + * that only `explain:command` consumes. Keeping it inline on the route object + * shipped these strings in `dist/src/internal/daemon.js`, so — like the + * per-command claims in `command-descriptor/owner-files.ts` — they live here in + * a module the production import graph never reaches, and the bundler drops them. + * + * `satisfies Record` keeps the map complete: adding + * or renaming a route in {@link DAEMON_ROUTE_HANDLERS} is a compile error until + * this map matches. The `request-handler-chain` parity test additionally asserts + * each path still points at the module that route's loader imports. + */ +const DAEMON_ROUTE_OWNER_FILES = { + lease: 'src/daemon/handlers/lease.ts', + session: 'src/daemon/handlers/session.ts', + snapshot: 'src/daemon/handlers/snapshot.ts', + reactNative: 'src/daemon/handlers/react-native.ts', + recordTrace: 'src/daemon/handlers/record-trace.ts', + find: 'src/daemon/handlers/find.ts', + interaction: 'src/daemon/handlers/interaction.ts', + generic: 'src/daemon/request-generic-dispatch.ts', +} as const satisfies Record; + +export function getDaemonRouteOwnerFiles(): Record { + return { ...DAEMON_ROUTE_OWNER_FILES }; +} diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000000..0c5e15aa95 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,6 @@ +/** + * Build-time flag: owner-file claims are enabled outside production bundles. + * The identifier is undefined in dev/tests and `false` in production builds, + * so the source defaults it on with `typeof __OWNER_FILES__ === 'undefined'`. + */ +declare const __OWNER_FILES__: boolean; diff --git a/test/output-economy/owner-files-no-leak.test.ts b/test/output-economy/owner-files-no-leak.test.ts new file mode 100644 index 0000000000..38081691ab --- /dev/null +++ b/test/output-economy/owner-files-no-leak.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runCmdSync } from '../../src/utils/exec.ts'; +import { COMMAND_OWNER_FILES } from '../../src/core/command-descriptor/owner-files.ts'; +import { getDaemonRouteOwnerFiles } from '../../src/daemon/route-owner-files.ts'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const distPath = path.join(repoRoot, 'dist/src'); + +function collectJsFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { recursive: true, encoding: 'utf8' }); + return entries.filter((name) => name.endsWith('.js')).map((name) => path.join(dir, name)); +} + +describe('owner-file metadata', () => { + test('does not leak into production bundles after a clean build', { timeout: 30_000 }, () => { + fs.rmSync(path.join(repoRoot, 'dist'), { recursive: true, force: true }); + + const build = runCmdSync( + 'node', + [ + '--experimental-strip-types', + 'node_modules/tsdown/dist/run.mjs', + '--config-loader', + 'native', + ], + { cwd: repoRoot }, + ); + expect(build.exitCode, build.stderr).toBe(0); + + const jsFiles = collectJsFiles(distPath); + const bundle = jsFiles.map((file) => fs.readFileSync(file, 'utf8')).join('\n'); + + const forbiddenMetadata = [ + 'ownerFiles', + ...Object.values(COMMAND_OWNER_FILES).flat(), + ...Object.values(getDaemonRouteOwnerFiles()), + ]; + + for (const value of forbiddenMetadata) { + expect( + bundle.includes(value), + `owner-file metadata ${value} leaked into production bundle`, + ).toBe(false); + } + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 372a561193..dbec3bd565 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -71,6 +71,7 @@ export default defineConfig({ tsconfig: 'tsconfig.lib.json', define: { __AGENT_DEVICE_VERSION__: JSON.stringify(packageJson.version), + __OWNER_FILES__: 'false', }, shims: true, hash: false,