Skip to content
Merged
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions scripts/check-bundle-owner-files.ts
Original file line number Diff line number Diff line change
@@ -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];
});
}
2 changes: 1 addition & 1 deletion scripts/explain-command.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
14 changes: 12 additions & 2 deletions src/commands/__tests__/command-explain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/commands/command-explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
23 changes: 23 additions & 0 deletions src/core/command-descriptor/__tests__/owner-files.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
48 changes: 48 additions & 0 deletions src/core/command-descriptor/owner-files.ts
Original file line number Diff line number Diff line change
@@ -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<T[K]['ownerFiles']>;
};

const buildOwnerFiles = <
const T extends readonly {
readonly name: string;
readonly ownerFiles?: readonly [string, ...string[]];
}[],
>(
entries: T,
): OwnerFilesFromDescriptors<T> =>
Object.fromEntries(
entries.map((entry) => [entry.name, entry.ownerFiles]),
) as OwnerFilesFromDescriptors<T>;

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];
}
Loading
Loading