diff --git a/packages/platform-api-docs/CHANGELOG.md b/packages/platform-api-docs/CHANGELOG.md
index 18d0182c352..dc6fb9d7a20 100644
--- a/packages/platform-api-docs/CHANGELOG.md
+++ b/packages/platform-api-docs/CHANGELOG.md
@@ -9,6 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- Initial release of the platform-api-docs package ([#8012](https://github.com/MetaMask/core/pull/8012))
+- Initial release of the platform-api-docs package ([#8012](https://github.com/MetaMask/core/pull/8012), [#9913](https://github.com/MetaMask/core/pull/9913))
[Unreleased]: https://github.com/MetaMask/core/
diff --git a/packages/platform-api-docs/README.md b/packages/platform-api-docs/README.md
index 2abc7e405b1..a85710ba948 100644
--- a/packages/platform-api-docs/README.md
+++ b/packages/platform-api-docs/README.md
@@ -35,12 +35,61 @@ Options:
--build Generate docs and build static site
--serve Generate docs, build, and serve static site
--dev Generate docs and start dev server with hot reload
- --scan-dir
Extra source directory to scan (repeatable)
+ --strategy How to find actions and events: "scan" (default) or
+ "root-messenger" (see below)
+ --scan-dir Extra source directory to scan (repeatable; --strategy scan only)
+ --root-actions [ Type aliasing the union of every action, as "]#"
+ (required with --strategy root-messenger)
+ --root-events [ Type aliasing the union of every event, as "]#"
+ (required with --strategy root-messenger)
--output Output directory (default: /.platform-api-docs)
--project-label Short label identifying the project (e.g. "Core", "Extension")
--help Show this help message
```
+## Strategies
+
+Which strategy to use depends on whether the project has a single messenger
+carrying every action and event.
+
+### `scan` (default)
+
+Parses every TypeScript source and declaration file it can find — the scan
+directories, `packages/*/src`, and `node_modules/@metamask/*/dist` — and reads
+every `*Messenger` type alias it encounters.
+
+Use this when no single messenger aggregates every capability, as in a monorepo
+of independently published packages.
+
+### `root-messenger`
+
+Resolves the two types the project declares for its root messenger — the union
+of every action and the union of every event — and lets the TypeScript type
+checker enumerate them. Only the files named on the command line are opened.
+
+Use this when the project has one root messenger carrying every action and
+event, as a client application built on these packages does. It is
+substantially faster than `scan`, because it reads what the project already
+declares instead of re-deriving it, and it documents only what is reachable
+through that messenger.
+
+```
+platform-api-docs \
+ --strategy root-messenger \
+ --root-actions 'src/messenger.ts#RootActions' \
+ --root-events 'src/messenger.ts#RootEvents'
+```
+
+Each reference names a type alias, written by hand or computed — the type
+checker resolves either.
+
+The docs contain exactly what the named types contain, so those types should be
+the ones carrying every capability rather than a narrowed subset. Capability
+types that can't be documented are reported rather than dropped silently: those
+declared inline in the union (with no name or JSDoc to document), and those
+whose shape can't be read (most often a `type` property that isn't a namespaced
+string literal).
+
## Contributing
This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).
diff --git a/packages/platform-api-docs/src/cli.test.ts b/packages/platform-api-docs/src/cli.test.ts
index bc084e33afe..0e098788b6e 100644
--- a/packages/platform-api-docs/src/cli.test.ts
+++ b/packages/platform-api-docs/src/cli.test.ts
@@ -164,4 +164,175 @@ export type QuxMessenger = Messenger<'Qux', QuxAction, never>;
expect(result.all).toContain('No scannable directories found');
});
});
+
+ describe('--strategy root-messenger', () => {
+ /**
+ * Write a project whose root messenger unions live in `app/types.ts`.
+ *
+ * @param directoryPath - The sandbox root.
+ */
+ async function writeRootMessengerProject(
+ directoryPath: string,
+ ): Promise {
+ const appDir = path.join(directoryPath, 'app');
+ await fs.promises.mkdir(appDir, { recursive: true });
+ await fs.promises.writeFile(
+ path.join(appDir, 'types.ts'),
+ `
+export type FooControllerGetStateAction = {
+ type: 'FooController:getState';
+ handler: () => FooState;
+};
+
+export type FooControllerStateChangeEvent = {
+ type: 'FooController:stateChange';
+ payload: [FooState, Patch[]];
+};
+
+export type GlobalActions = FooControllerGetStateAction;
+export type GlobalEvents = FooControllerStateChangeEvent;
+`,
+ );
+ }
+
+ it('generates docs from the named root messenger unions', async () => {
+ expect.assertions(3);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeRootMessengerProject(directoryPath);
+
+ const result = await runCLI([
+ directoryPath,
+ '--strategy',
+ 'root-messenger',
+ '--root-actions',
+ 'app/types.ts#GlobalActions',
+ '--root-events',
+ 'app/types.ts#GlobalEvents',
+ ]);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.all).toContain('Found 2 messenger items total');
+ expect(result.all).toContain('Generated docs for 1 namespace');
+ });
+ });
+
+ it('exits with error when the root type references are missing', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeRootMessengerProject(directoryPath);
+
+ const result = await runCLI([
+ directoryPath,
+ '--strategy',
+ 'root-messenger',
+ ]);
+
+ expect(result.exitCode).not.toBe(0);
+ expect(result.all).toContain(
+ 'requires both --root-actions and --root-events',
+ );
+ });
+ });
+
+ it('exits with error when a root type reference is malformed', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeRootMessengerProject(directoryPath);
+
+ const result = await runCLI([
+ directoryPath,
+ '--strategy',
+ 'root-messenger',
+ '--root-actions',
+ 'app/types.ts',
+ '--root-events',
+ 'app/types.ts#GlobalEvents',
+ ]);
+
+ expect(result.exitCode).not.toBe(0);
+ expect(result.all).toContain(
+ 'Expected a reference of the form "#"',
+ );
+ });
+ });
+
+ it('exits with error when the named type is not declared', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeRootMessengerProject(directoryPath);
+
+ const result = await runCLI([
+ directoryPath,
+ '--strategy',
+ 'root-messenger',
+ '--root-actions',
+ 'app/types.ts#NotDeclared',
+ '--root-events',
+ 'app/types.ts#GlobalEvents',
+ ]);
+
+ expect(result.exitCode).not.toBe(0);
+ expect(result.all).toContain('No type alias named "NotDeclared"');
+ });
+ });
+
+ it('exits with error when --scan-dir is combined with it', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeRootMessengerProject(directoryPath);
+
+ const result = await runCLI([
+ directoryPath,
+ '--strategy',
+ 'root-messenger',
+ '--root-actions',
+ 'app/types.ts#GlobalActions',
+ '--root-events',
+ 'app/types.ts#GlobalEvents',
+ '--scan-dir',
+ 'app',
+ ]);
+
+ expect(result.exitCode).not.toBe(0);
+ expect(result.all).toContain(
+ '--scan-dir only applies to --strategy scan',
+ );
+ });
+ });
+
+ it('exits with error when root type references are used with --strategy scan', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeRootMessengerProject(directoryPath);
+
+ const result = await runCLI([
+ directoryPath,
+ '--root-actions',
+ 'app/types.ts#GlobalActions',
+ ]);
+
+ expect(result.exitCode).not.toBe(0);
+ expect(result.all).toContain(
+ '--root-actions and --root-events only apply to --strategy root-messenger',
+ );
+ });
+ });
+
+ it('rejects an unknown strategy', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ const result = await runCLI([directoryPath, '--strategy', 'telepathy']);
+
+ expect(result.exitCode).not.toBe(0);
+ expect(result.all).toContain('Invalid values');
+ });
+ });
+ });
});
diff --git a/packages/platform-api-docs/src/cli.ts b/packages/platform-api-docs/src/cli.ts
index e6b911828f3..3741496ca05 100644
--- a/packages/platform-api-docs/src/cli.ts
+++ b/packages/platform-api-docs/src/cli.ts
@@ -7,6 +7,8 @@ import npmWhich from 'npm-which';
import yargs from 'yargs';
import { generate, resolveRepoUrl } from './generate.js';
+import type { RootTypeReference } from './root-messenger-discovery.js';
+import { parseRootTypeReference } from './root-messenger-discovery.js';
/**
* Locate the Docusaurus binary in this package's `node_modules/.bin`. Using
@@ -119,6 +121,53 @@ async function resolveCommitSha(projectPath: string): Promise {
}
}
+/** The subset of parsed arguments {@link checkStrategyArgs} validates. */
+type StrategyArgs = {
+ strategy?: string;
+ 'root-actions'?: unknown;
+ 'root-events'?: unknown;
+ 'scan-dir'?: string[];
+};
+
+/**
+ * Reject flag combinations that don't make sense, so a mistaken invocation
+ * fails instead of silently producing docs built the wrong way.
+ *
+ * Flags belonging to the strategy that wasn't selected are errors rather than
+ * ignored. Used as a yargs `.check`, so failures print alongside usage.
+ *
+ * @param argv - The parsed arguments.
+ * @returns True when the combination is valid.
+ */
+function checkStrategyArgs(argv: StrategyArgs): boolean {
+ const rootActions = argv['root-actions'];
+ const rootEvents = argv['root-events'];
+
+ if (argv.strategy !== 'root-messenger') {
+ if (rootActions !== undefined || rootEvents !== undefined) {
+ throw new Error(
+ '--root-actions and --root-events only apply to --strategy root-messenger.',
+ );
+ }
+ return true;
+ }
+
+ if (rootActions === undefined || rootEvents === undefined) {
+ throw new Error(
+ '--strategy root-messenger requires both --root-actions and --root-events, ' +
+ 'each written as "#".',
+ );
+ }
+ if ((argv['scan-dir'] ?? []).length > 0) {
+ throw new Error(
+ '--scan-dir only applies to --strategy scan; --strategy root-messenger reads ' +
+ 'only the files named by --root-actions and --root-events.',
+ );
+ }
+
+ return true;
+}
+
/**
* Main CLI entry point.
*/
@@ -153,6 +202,23 @@ async function main(): Promise {
'Generate platform API docs and serve a development-only site',
default: false,
})
+ .option('strategy', {
+ type: 'string',
+ choices: ['scan', 'root-messenger'],
+ description:
+ 'How to find messenger actions and events. "scan" parses every source and declaration file looking for messenger types. "root-messenger" instead resolves the two types the project declares for its root messenger',
+ default: 'scan',
+ })
+ .option('root-actions', {
+ type: 'string',
+ description:
+ 'Type aliasing the union of every action on the root messenger, written as "#" (required with --strategy root-messenger)',
+ })
+ .option('root-events', {
+ type: 'string',
+ description:
+ 'Type aliasing the union of every event on the root messenger, written as "#" (required with --strategy root-messenger)',
+ })
.option('scan-dir', {
type: 'string',
array: true,
@@ -179,6 +245,9 @@ async function main(): Promise {
description:
'Path prefix the built site will be served under, e.g. /core/platform-api/',
})
+ .coerce('root-actions', parseRootTypeReference)
+ .coerce('root-events', parseRootTypeReference)
+ .check(checkStrategyArgs)
.help().argv;
const projectPathArg = argv['project-path'];
@@ -203,9 +272,15 @@ async function main(): Promise {
await generate({
projectPath: resolvedProjectPath,
outputDir: resolvedOutputDir,
- scanDirs,
projectLabel,
commitSha,
+ ...(argv.strategy === 'root-messenger'
+ ? {
+ strategy: 'root-messenger' as const,
+ rootActions: argv['root-actions'] as RootTypeReference,
+ rootEvents: argv['root-events'] as RootTypeReference,
+ }
+ : { strategy: 'scan' as const, scanDirs }),
});
// Step 2: If --build, --serve, or --dev, set up and run Docusaurus
diff --git a/packages/platform-api-docs/src/extraction.test.ts b/packages/platform-api-docs/src/extraction.test.ts
index 35c1c1bd55c..50d9ab4df09 100644
--- a/packages/platform-api-docs/src/extraction.test.ts
+++ b/packages/platform-api-docs/src/extraction.test.ts
@@ -604,6 +604,44 @@ export type FooAction = {
});
});
+ it('escapes angle brackets in JSDoc for MDX safety', async () => {
+ expect.assertions(3);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ const filePath = path.join(directoryPath, 'types.ts');
+
+ const items = await extractFromWrittenFile(
+ filePath,
+ withMessenger(
+ `
+/**
+ * Reads a Promise from somewhere.
+ *
+ * @param filter - Accepts an Array of ids.
+ * @returns Promise - The active boosts.
+ */
+export type FooAction = {
+ type: 'Foo:bar';
+ handler: (filter: string[]) => void;
+};
+`,
+ { actions: ['FooAction'] },
+ ),
+ directoryPath,
+ );
+
+ // An unescaped `<` is read by MDX as the start of a JSX tag, which
+ // fails the site build rather than rendering.
+ expect(items[0].jsDoc).toContain('Promise\\ from somewhere.');
+ expect(items[0].params[0].description).toContain(
+ 'Array\\ of ids.',
+ );
+ expect(items[0].returns).toBe(
+ 'Promise\\ - The active boosts.',
+ );
+ });
+ });
+
it('extracts multiple types from the same file', async () => {
expect.assertions(3);
diff --git a/packages/platform-api-docs/src/extraction.ts b/packages/platform-api-docs/src/extraction.ts
index 1b41bad2821..5b3c2f43d37 100644
--- a/packages/platform-api-docs/src/extraction.ts
+++ b/packages/platform-api-docs/src/extraction.ts
@@ -41,17 +41,14 @@ import type {
*/
function escapeJsDocTextForMdx(text: string): string {
const withLinksResolved = text.replace(/\{@link\s+([^}]+)\}/gu, '`$1`');
+ // Escape the characters MDX reads as syntax rather than text: `{` and `}`
+ // open an expression, and `<` opens a JSX tag — so an unescaped return type
+ // like `Promise` fails the site build. Content already inside a code
+ // span is left alone.
return withLinksResolved.replace(
- /`[^`]*`|(\{)|(\})/gu,
- (match, open: string | undefined, close: string | undefined) => {
- if (open) {
- return '\\{';
- }
- if (close) {
- return '\\}';
- }
- return match;
- },
+ /`[^`]*`|([{}<])/gu,
+ (match, special: string | undefined) =>
+ special === undefined ? match : `\\${special}`,
);
}
@@ -313,6 +310,67 @@ type MessengerCapabilityTypeDeclaration =
| ConstructorMessengerCapabilityTypeDeclaration
| ObjectMessengerCapabilityTypeDeclaration;
+/**
+ * Tag a capability type declaration with the body shape it has, so the right
+ * extractor can read it: 'constructor' for a capability-type-constructor
+ * invocation such as `ControllerGetStateAction<...>`, 'object' otherwise.
+ *
+ * Callers must resolve unions and bare type references first, so that the
+ * declaration reaching here is a leaf.
+ *
+ * @param declaration - The type alias or interface to classify.
+ * @param kind - Whether to tag the declaration as 'action' or 'event'.
+ * @returns The tagged declaration, or null for a qualified-name reference.
+ */
+export function classifyMessengerCapabilityTypeDeclaration(
+ declaration: TypeAliasDeclaration | InterfaceDeclaration,
+ kind: 'action' | 'event',
+): MessengerCapabilityTypeDeclaration | null {
+ // Interfaces always carry their members directly.
+ // EXAMPLE:
+ // interface FooControllerSomeAction { ... }
+ if (NodeGuards.isInterfaceDeclaration(declaration)) {
+ return { bodyShape: 'object', kind, declaration };
+ }
+
+ const body = declaration.getTypeNode();
+
+ // A TypeReference body is a capability-type-constructor invocation (e.g.
+ // `ControllerGetStateAction`). Tag it so the constructor
+ // extractor can read `body` directly without re-checking its shape.
+ // EXAMPLE:
+ // type FooControllerSomeAction = ControllerGetStateAction<...>
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ if (body && NodeGuards.isTypeReference(body)) {
+ // Reject qualified-name constructor type names, as we need a plain
+ // identifier to match the constructor by name.
+ // EXAMPLE:
+ // import * as somePackage from '....js';
+ // type FooControllerSomeAction = somePackage.ControllerGetStateAction<...>
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ const constructorTypeName = body.getTypeName();
+ if (!NodeGuards.isIdentifier(constructorTypeName)) {
+ return null;
+ }
+
+ return {
+ bodyShape: 'constructor',
+ kind,
+ declaration,
+ body,
+ typeName: constructorTypeName,
+ };
+ }
+
+ // Anything else (a type literal, intersection, conditional, …) goes to the
+ // literal extractor, which knows how to read members off a type literal and
+ // rejects exotic shapes.
+ // EXAMPLE:
+ // type FooControllerSomeAction = { ... }
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ return { bodyShape: 'object', kind, declaration };
+}
+
/**
* Represents a type alias for a messenger. Only includes nodes representing the
* `Actions` and `Events` type parameters.
@@ -539,45 +597,22 @@ function recursivelyFindMessengerCapabilityTypeDeclarations(
continue;
}
- // A TypeReference body with type arguments is a capability-type-
+ // Everything else is a leaf capability type — a capability-type-
// constructor invocation (e.g. `ControllerGetStateAction`). Tag it so the constructor extractor can read `body`
- // directly without re-checking its shape.
- // EXAMPLE:
+ // State>`) or a literal object type. Tag it so the matching extractor
+ // can read it without re-checking its shape.
+ // EXAMPLES:
// type FooControllerSomeAction = ControllerGetStateAction<...>
- // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- if (body && NodeGuards.isTypeReference(body)) {
- // Reject qualified-name constructor type names, as we need a plain
- // identifier to match the constructor by name.
- // EXAMPLE:
- // // Bad
- // import * as somePackage from '....js';
- // type FooControllerSomeAction = somePackage.ControllerGetStateAction<...>
- // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- const constructorTypeName = body.getTypeName();
- if (NodeGuards.isIdentifier(constructorTypeName)) {
- result.capabilityTypeDeclarations.push({
- bodyShape: 'constructor',
- kind,
- declaration,
- body,
- typeName: constructorTypeName,
- });
- }
- continue;
- }
-
- // Anything else (a type literal, intersection, conditional, …) gets
- // tagged for the literal extractor, which knows how to read members
- // off a type literal and rejects exotic shapes.
- // EXAMPLE:
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// type FooControllerSomeAction = { ... }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
- result.capabilityTypeDeclarations.push({
- bodyShape: 'object',
- kind,
+ const classified = classifyMessengerCapabilityTypeDeclaration(
declaration,
- });
+ kind,
+ );
+ if (classified) {
+ result.capabilityTypeDeclarations.push(classified);
+ }
}
// Interfaces always carry their members directly — tag for the literal
@@ -612,7 +647,7 @@ function recursivelyFindMessengerCapabilityTypeDeclarations(
* @returns Information that may be extracted from the messenger capability type
* (may be `null` if the type is ineligible for extraction).
*/
-function extractFromMessengerCapabilityTypeDeclaration(
+export function extractFromMessengerCapabilityTypeDeclaration(
capabilityTypeDeclaration: MessengerCapabilityTypeDeclaration,
projectPath: string,
): MessengerCapabilityPacket | null {
diff --git a/packages/platform-api-docs/src/generate.test.ts b/packages/platform-api-docs/src/generate.test.ts
index bfc823e558c..cc12329b33c 100644
--- a/packages/platform-api-docs/src/generate.test.ts
+++ b/packages/platform-api-docs/src/generate.test.ts
@@ -903,3 +903,241 @@ describe('resolveRepoUrl', () => {
});
});
});
+
+describe('generate with the root-messenger strategy', () => {
+ it('generates docs from the root messenger unions without scanning', async () => {
+ expect.assertions(4);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ const appDir = path.join(directoryPath, 'app');
+ await fs.promises.mkdir(appDir, { recursive: true });
+ await fs.promises.writeFile(
+ path.join(appDir, 'types.ts'),
+ `
+/**
+ * Retrieves the state of the FooController.
+ */
+export type FooControllerGetStateAction = {
+ type: 'FooController:getState';
+ handler: () => FooState;
+};
+
+export type FooControllerStateChangeEvent = {
+ type: 'FooController:stateChange';
+ payload: [FooState, Patch[]];
+};
+
+export type GlobalActions = FooControllerGetStateAction;
+export type GlobalEvents = FooControllerStateChangeEvent;
+`,
+ );
+
+ // A messenger type outside the root unions. The scan strategy would pick
+ // it up; the root-messenger strategy must not, since it isn't reachable
+ // from the root messenger.
+ await fs.promises.writeFile(
+ path.join(appDir, 'unreachable.ts'),
+ `
+export type OrphanDoAction = {
+ type: 'Orphan:do';
+ handler: () => void;
+};
+
+export type OrphanMessenger = Messenger<'Orphan', OrphanDoAction, never>;
+`,
+ );
+
+ const outputDir = path.join(directoryPath, '.docs');
+ const result = await generate({
+ projectPath: directoryPath,
+ outputDir,
+ strategy: 'root-messenger',
+ rootActions: {
+ filePath: path.join('app', 'types.ts'),
+ typeName: 'GlobalActions',
+ },
+ rootEvents: {
+ filePath: path.join('app', 'types.ts'),
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result).toStrictEqual({ namespaces: 1, actions: 1, events: 1 });
+
+ const docsDir = path.join(outputDir, 'docs');
+ const actionsMd = await fs.promises.readFile(
+ path.join(docsDir, 'FooController', 'actions.md'),
+ 'utf8',
+ );
+ expect(actionsMd).toContain('FooController:getState');
+ expect(actionsMd).toContain('Retrieves the state of the FooController.');
+ expect(await fs.promises.readdir(docsDir)).not.toContain('Orphan');
+ });
+ });
+
+ it('warns about capability types it could not document', async () => {
+ expect.assertions(3);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ const appDir = path.join(directoryPath, 'app');
+ await fs.promises.mkdir(appDir, { recursive: true });
+ await fs.promises.writeFile(
+ path.join(appDir, 'types.ts'),
+ `
+export type GoodAction = {
+ type: 'Good:do';
+ handler: () => void;
+};
+
+export type UnnamespacedAction = {
+ type: 'nocolon';
+ handler: () => void;
+};
+
+export type GlobalActions =
+ | GoodAction
+ | UnnamespacedAction
+ | { type: 'Anonymous:do'; handler: () => void };
+
+export type GlobalEvents = never;
+`,
+ );
+
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
+ // Silence the warnings while asserting on them.
+ });
+
+ try {
+ const result = await generate({
+ projectPath: directoryPath,
+ outputDir: path.join(directoryPath, '.docs'),
+ strategy: 'root-messenger',
+ rootActions: {
+ filePath: path.join('app', 'types.ts'),
+ typeName: 'GlobalActions',
+ },
+ rootEvents: {
+ filePath: path.join('app', 'types.ts'),
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result).toStrictEqual({ namespaces: 1, actions: 1, events: 0 });
+ expect(warn).toHaveBeenCalledWith(
+ 'Warning: skipped 1 capability type declared inline, with no name to document: { type: "Anonymous:do"; handler: () => void; }',
+ );
+ expect(warn).toHaveBeenCalledWith(
+ `Warning: skipped 1 capability type whose shape could not be read: UnnamespacedAction (${path.join('app', 'types.ts')}:7)`,
+ );
+ } finally {
+ warn.mockRestore();
+ }
+ });
+ });
+
+ it('pluralizes the skipped-capability warnings and truncates long lists', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ const appDir = path.join(directoryPath, 'app');
+ await fs.promises.mkdir(appDir, { recursive: true });
+ // Twelve inline members, so the warning names ten and summarizes two.
+ const anonymousMembers = Array.from(
+ { length: 12 },
+ (_unused, index) =>
+ ` | { type: 'Anonymous:member${index}'; handler: () => void }`,
+ ).join('\n');
+ await fs.promises.writeFile(
+ path.join(appDir, 'types.ts'),
+ `
+export type FirstBadAction = { type: 'nocolon1'; handler: () => void };
+export type SecondBadAction = { type: 'nocolon2'; handler: () => void };
+
+export type GlobalActions =
+ | FirstBadAction
+ | SecondBadAction
+${anonymousMembers};
+
+export type GlobalEvents = never;
+`,
+ );
+
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
+ // Silence the warnings while asserting on them.
+ });
+
+ try {
+ await generate({
+ projectPath: directoryPath,
+ outputDir: path.join(directoryPath, '.docs'),
+ strategy: 'root-messenger',
+ rootActions: {
+ filePath: path.join('app', 'types.ts'),
+ typeName: 'GlobalActions',
+ },
+ rootEvents: {
+ filePath: path.join('app', 'types.ts'),
+ typeName: 'GlobalEvents',
+ },
+ }).catch(() => undefined);
+
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /^Warning: skipped 12 capability types declared inline, with no name to document: .*, and 2 more$/u,
+ ),
+ );
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /^Warning: skipped 2 capability types whose shape could not be read: FirstBadAction \(.*\), SecondBadAction \(.*\)$/u,
+ ),
+ );
+ } finally {
+ warn.mockRestore();
+ }
+ });
+ });
+
+ it('throws rather than emptying the docs when nothing resolves', async () => {
+ expect.assertions(1);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ const appDir = path.join(directoryPath, 'app');
+ await fs.promises.mkdir(appDir, { recursive: true });
+ await fs.promises.writeFile(
+ path.join(appDir, 'types.ts'),
+ `
+import type { Missing } from 'this-package-does-not-exist';
+
+export type GlobalActions = Missing;
+export type GlobalEvents = never;
+`,
+ );
+
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
+ // Silence the warnings; this test is about the throw.
+ });
+
+ try {
+ await expect(
+ generate({
+ projectPath: directoryPath,
+ outputDir: path.join(directoryPath, '.docs'),
+ strategy: 'root-messenger',
+ rootActions: {
+ filePath: path.join('app', 'types.ts'),
+ typeName: 'GlobalActions',
+ },
+ rootEvents: {
+ filePath: path.join('app', 'types.ts'),
+ typeName: 'GlobalEvents',
+ },
+ }),
+ ).rejects.toThrow(
+ 'named by --root-actions, resolved to `Missing` rather than a union',
+ );
+ } finally {
+ warn.mockRestore();
+ }
+ });
+ });
+});
diff --git a/packages/platform-api-docs/src/generate.ts b/packages/platform-api-docs/src/generate.ts
index 11d336c59dd..b71b714f760 100644
--- a/packages/platform-api-docs/src/generate.ts
+++ b/packages/platform-api-docs/src/generate.ts
@@ -15,6 +15,8 @@ import {
generateNamespacePage,
generateSidebars,
} from './markdown.js';
+import type { RootTypeReference } from './root-messenger-discovery.js';
+import { discoverFromRootMessenger } from './root-messenger-discovery.js';
import type { MessengerCapabilityPacket, NamespaceGroup } from './types.js';
/**
@@ -128,6 +130,33 @@ async function resolveRepoBaseUrl(
return `${repoUrl}/blob/${ref}/`;
}
+/**
+ * Options for the `scan` strategy, which reads every `*Messenger` type alias
+ * in every file it can find. Used when no single messenger aggregates every
+ * capability.
+ */
+type ScanStrategyOptions = {
+ strategy?: 'scan';
+ /** Directories (relative to projectPath) to scan for .ts source files. */
+ scanDirs: string[];
+ rootActions?: never;
+ rootEvents?: never;
+};
+
+/**
+ * Options for the `root-messenger` strategy, which resolves the unions a
+ * project declares for its root messenger instead of scanning. Used when one
+ * messenger carries every action and event.
+ */
+type RootMessengerStrategyOptions = {
+ strategy: 'root-messenger';
+ scanDirs?: never;
+ /** Type aliasing the union of every action. */
+ rootActions: RootTypeReference;
+ /** Type aliasing the union of every event. */
+ rootEvents: RootTypeReference;
+};
+
/**
* Options for the generate function.
*/
@@ -136,8 +165,6 @@ export type GenerateOptions = {
projectPath: string;
/** Absolute path to the output directory for generated docs. */
outputDir: string;
- /** Directories (relative to projectPath) to scan for .ts source files. */
- scanDirs: string[];
/**
* Short label identifying the project the docs were generated from (e.g.
* "Core", "Extension"). Stamped in the index page title.
@@ -148,7 +175,7 @@ export type GenerateOptions = {
* intro so engineers know how current the site is.
*/
commitSha?: string | null;
-};
+} & (ScanStrategyOptions | RootMessengerStrategyOptions);
/**
* Result returned by the generate function.
@@ -510,16 +537,17 @@ async function writeOutput(
}
/**
- * Scan a project for messenger action/event types and generate documentation.
+ * Collect capabilities by scanning the project's files.
*
- * @param options - Generation options.
- * @returns A promise resolving to counts of generated namespaces, actions, and events.
+ * @param projectPath - The project root path.
+ * @param scanDirs - Directories (relative to projectPath) to scan.
+ * @returns The extracted capabilities.
+ * @throws If the project has no scannable directories at all.
*/
-export async function generate(
- options: GenerateOptions,
-): Promise {
- const { projectPath, outputDir, scanDirs, projectLabel, commitSha } = options;
-
+async function collectByScanning(
+ projectPath: string,
+ scanDirs: string[],
+): Promise {
const sources = await discoverScanSources(projectPath, scanDirs);
if (
@@ -535,7 +563,95 @@ export async function generate(
logScanPlan(sources);
- const allItems = await scanSources(projectPath, sources);
+ return await scanSources(projectPath, sources);
+}
+
+/**
+ * Collect capabilities by resolving the project's root messenger unions.
+ *
+ * @param projectPath - The project root path.
+ * @param options - The root-messenger strategy options.
+ * @returns The extracted capabilities.
+ */
+function collectFromRootMessenger(
+ projectPath: string,
+ options: RootMessengerStrategyOptions,
+): MessengerCapabilityPacket[] {
+ const { rootActions, rootEvents } = options;
+
+ console.log(
+ `Resolving actions from ${rootActions.filePath}#${rootActions.typeName} ` +
+ `and events from ${rootEvents.filePath}#${rootEvents.typeName}...`,
+ );
+
+ const { packets, skipped } = discoverFromRootMessenger({
+ projectPath,
+ actions: rootActions,
+ events: rootEvents,
+ });
+
+ // Report rather than drop silently: a jump in any of these usually means the
+ // project changed how it declares its capabilities.
+ warnSkipped('declared inline, with no name to document', skipped.unnamed);
+ warnSkipped('whose shape could not be read', skipped.unextractable);
+
+ // Both unions resolving to nothing is always a misconfiguration — a wrong
+ // type name, or imports that didn't resolve. Failing here matters because
+ // generation would otherwise replace an existing docs directory with an
+ // empty one and exit successfully.
+ if (packets.length === 0) {
+ throw new Error(
+ `No messenger actions or events found in ` +
+ `${rootActions.filePath}#${rootActions.typeName} or ` +
+ `${rootEvents.filePath}#${rootEvents.typeName}. ` +
+ `Check that these types name the unions carrying every capability, ` +
+ `and that their imports resolve.`,
+ );
+ }
+
+ return packets;
+}
+
+/** How many skipped capability types to name before summarizing the rest. */
+const MAX_SKIPPED_SHOWN = 10;
+
+/**
+ * Warn about capability types that couldn't be documented, naming them so the
+ * warning is actionable.
+ *
+ * @param description - Why they were skipped, as a noun phrase.
+ * @param labels - Labels identifying each skipped type.
+ */
+function warnSkipped(description: string, labels: string[]): void {
+ if (labels.length === 0) {
+ return;
+ }
+
+ const shown = labels.slice(0, MAX_SKIPPED_SHOWN);
+ const remaining = labels.length - shown.length;
+ console.warn(
+ `Warning: skipped ${labels.length} capability ` +
+ `${labels.length === 1 ? 'type' : 'types'} ${description}: ` +
+ `${shown.join(', ')}${remaining > 0 ? `, and ${remaining} more` : ''}`,
+ );
+}
+
+/**
+ * Scan a project for messenger action/event types and generate documentation.
+ *
+ * @param options - Generation options.
+ * @returns A promise resolving to counts of generated namespaces, actions, and events.
+ */
+export async function generate(
+ options: GenerateOptions,
+): Promise {
+ const { projectPath, outputDir, projectLabel, commitSha } = options;
+
+ const allItems =
+ options.strategy === 'root-messenger'
+ ? collectFromRootMessenger(projectPath, options)
+ : await collectByScanning(projectPath, options.scanDirs);
+
console.log(
`Found ${allItems.length} messenger ${allItems.length === 1 ? 'item' : 'items'} total.`,
);
diff --git a/packages/platform-api-docs/src/root-messenger-discovery.test.ts b/packages/platform-api-docs/src/root-messenger-discovery.test.ts
new file mode 100644
index 00000000000..623d1bc0ac5
--- /dev/null
+++ b/packages/platform-api-docs/src/root-messenger-discovery.test.ts
@@ -0,0 +1,808 @@
+import { createSandbox } from '@metamask/utils/node';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+import {
+ discoverFromRootMessenger,
+ parseRootTypeReference,
+} from './root-messenger-discovery.js';
+
+const { withinSandbox } = createSandbox(
+ 'platform-api-docs/root-messenger-discovery',
+);
+
+jest.setTimeout(60_000);
+
+/**
+ * A self-contained stand-in for `@metamask/messenger`'s `Messenger` type plus
+ * the `MessengerActions`/`MessengerEvents` projections. Prepended to fixtures
+ * that mirror the extension's derived-union shape so the fixtures resolve
+ * without depending on the real package being installed in the sandbox.
+ */
+const MESSENGER_PRELUDE = `
+type Messenger = {
+ __namespace: Namespace;
+ __actions: Actions;
+ __events: Events;
+};
+
+type MessengerActions =
+ TMessenger extends Messenger
+ ? Actions
+ : never;
+
+type MessengerEvents =
+ TMessenger extends Messenger
+ ? Events
+ : never;
+`;
+
+/**
+ * Write a fixture file into a sandbox directory, creating parent directories
+ * as needed.
+ *
+ * @param directoryPath - The sandbox root.
+ * @param relativePath - Path of the file relative to the sandbox root.
+ * @param contents - The file contents.
+ */
+async function writeFixture(
+ directoryPath: string,
+ relativePath: string,
+ contents: string,
+): Promise {
+ const absolutePath = path.join(directoryPath, relativePath);
+ await fs.promises.mkdir(path.dirname(absolutePath), { recursive: true });
+ await fs.promises.writeFile(absolutePath, contents);
+}
+
+describe('parseRootTypeReference', () => {
+ it('splits a "#" reference into its parts', () => {
+ expect(
+ parseRootTypeReference('app/core/types.ts#GlobalActions'),
+ ).toStrictEqual({
+ filePath: 'app/core/types.ts',
+ typeName: 'GlobalActions',
+ });
+ });
+
+ it('keeps "#" characters that appear in the file path portion', () => {
+ expect(parseRootTypeReference('a#b/types.ts#GlobalActions')).toStrictEqual({
+ filePath: 'a#b/types.ts',
+ typeName: 'GlobalActions',
+ });
+ });
+
+ it('throws when the reference has no "#" separator', () => {
+ expect(() => parseRootTypeReference('app/core/types.ts')).toThrow(
+ 'Expected a reference of the form "#", got "app/core/types.ts".',
+ );
+ });
+
+ it('throws when the file path portion is empty', () => {
+ expect(() => parseRootTypeReference('#GlobalActions')).toThrow(
+ 'Expected a reference of the form "#", got "#GlobalActions".',
+ );
+ });
+
+ it('throws when the type name portion is empty', () => {
+ expect(() => parseRootTypeReference('app/core/types.ts#')).toThrow(
+ 'Expected a reference of the form "#", got "app/core/types.ts#".',
+ );
+ });
+});
+
+describe('discoverFromRootMessenger', () => {
+ it('extracts capabilities from a hand-written union of type references', async () => {
+ expect.assertions(3);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+/**
+ * Retrieves the state of the FooController.
+ */
+export type FooControllerGetStateAction = {
+ type: 'FooController:getState';
+ handler: () => FooState;
+};
+
+/**
+ * Published when the FooController's state changes.
+ */
+export type FooControllerStateChangeEvent = {
+ type: 'FooController:stateChange';
+ payload: [FooState, Patch[]];
+};
+
+export type GlobalActions = FooControllerGetStateAction;
+export type GlobalEvents = FooControllerStateChangeEvent;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toHaveLength(2);
+ expect(result.packets[0]).toMatchObject({
+ typeName: 'FooControllerGetStateAction',
+ typeString: 'FooController:getState',
+ kind: 'action',
+ jsDoc: 'Retrieves the state of the FooController.',
+ handlerOrPayload: '() => FooState',
+ sourceFile: path.join('app', 'types.ts'),
+ });
+ expect(result.packets[1]).toMatchObject({
+ typeName: 'FooControllerStateChangeEvent',
+ typeString: 'FooController:stateChange',
+ kind: 'event',
+ handlerOrPayload: '[FooState, Patch[]]',
+ });
+ });
+ });
+
+ it('extracts capabilities from a union derived through the type checker', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/messenger.ts',
+ `${MESSENGER_PRELUDE}
+/**
+ * Updates the accounts list.
+ */
+export type AccountOrderControllerUpdateAction = {
+ type: 'AccountOrderController:updateAccountsList';
+ handler: (accounts: string[]) => void;
+};
+
+export type AccountOrderControllerStateChangeEvent = {
+ type: 'AccountOrderController:stateChange';
+ payload: [AccountOrderState, Patch[]];
+};
+
+const MESSENGER_FACTORIES = {
+ accountOrder: {
+ getMessenger: () =>
+ ({}) as Messenger<
+ 'AccountOrderController',
+ AccountOrderControllerUpdateAction,
+ AccountOrderControllerStateChangeEvent
+ >,
+ },
+};
+
+type ChildMessengers = ReturnType<
+ (typeof MESSENGER_FACTORIES)[keyof typeof MESSENGER_FACTORIES]['getMessenger']
+>;
+
+export type RootMessengerActions = MessengerActions;
+export type RootMessengerEvents = MessengerEvents;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: {
+ filePath: 'app/messenger.ts',
+ typeName: 'RootMessengerActions',
+ },
+ events: {
+ filePath: 'app/messenger.ts',
+ typeName: 'RootMessengerEvents',
+ },
+ });
+
+ expect(result.packets).toMatchObject([
+ {
+ typeName: 'AccountOrderControllerUpdateAction',
+ typeString: 'AccountOrderController:updateAccountsList',
+ kind: 'action',
+ jsDoc: 'Updates the accounts list.',
+ },
+ {
+ typeName: 'AccountOrderControllerStateChangeEvent',
+ typeString: 'AccountOrderController:stateChange',
+ kind: 'event',
+ },
+ ]);
+ expect(result.skipped).toStrictEqual({
+ unnamed: [],
+ unextractable: [],
+ });
+ });
+ });
+
+ it('extracts capabilities declared via capability type constructors', async () => {
+ expect.assertions(1);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+type ControllerGetStateAction = {
+ type: \`\${Namespace}:getState\`;
+ handler: () => State;
+};
+
+type ControllerStateChangeEvent = {
+ type: \`\${Namespace}:stateChange\`;
+ payload: [State, Patch[]];
+};
+
+export type BarControllerGetStateAction = ControllerGetStateAction<
+ 'BarController',
+ BarState
+>;
+
+export type BarControllerStateChangeEvent = ControllerStateChangeEvent<
+ 'BarController',
+ BarState
+>;
+
+export type GlobalActions = BarControllerGetStateAction;
+export type GlobalEvents = BarControllerStateChangeEvent;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([
+ {
+ typeName: 'BarControllerGetStateAction',
+ typeString: 'BarController:getState',
+ kind: 'action',
+ handlerOrPayload: '() => BarState',
+ },
+ {
+ typeName: 'BarControllerStateChangeEvent',
+ typeString: 'BarController:stateChange',
+ kind: 'event',
+ handlerOrPayload: '[BarState, Patch[]]',
+ },
+ ]);
+ });
+ });
+
+ it('extracts capabilities declared as interfaces', async () => {
+ expect.assertions(1);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+/**
+ * Runs the qux routine.
+ */
+export interface QuxRunAction {
+ type: 'Qux:run';
+ handler: (times: number) => void;
+}
+
+export type GlobalActions = QuxRunAction;
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([
+ {
+ typeName: 'QuxRunAction',
+ typeString: 'Qux:run',
+ kind: 'action',
+ jsDoc: 'Runs the qux routine.',
+ handlerOrPayload: '(times: number) => void',
+ },
+ ]);
+ });
+ });
+
+ // TypeScript collapses `type A = B` before the checker hands us a
+ // constituent, so the docs name the type the chain ends at rather than the
+ // one the root union referenced. Surprising, but consistent with `scan`.
+ it('documents the underlying type when the union references an alias of an alias', async () => {
+ expect.assertions(1);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+export type BazDoActionOriginal = {
+ type: 'Baz:do';
+ handler: () => void;
+};
+
+export type BazDoAction = BazDoActionOriginal;
+
+export type GlobalActions = BazDoAction;
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toStrictEqual([
+ {
+ typeName: 'BazDoActionOriginal',
+ typeString: 'Baz:do',
+ kind: 'action',
+ jsDoc: '',
+ params: [],
+ returns: '',
+ handlerOrPayload: '() => void',
+ sourceFile: path.join('app', 'types.ts'),
+ line: 2,
+ deprecated: false,
+ },
+ ]);
+ });
+ });
+
+ it('labels anonymous constituents, truncating long ones', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+export type GoodAction = { type: 'Good:do'; handler: () => void };
+
+export type GlobalActions =
+ | GoodAction
+ | string
+ | {
+ type: 'Anonymous:withAnUnusuallyLongDeclaration';
+ handler: (first: string, second: number, third: boolean) => void;
+ };
+
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([{ typeString: 'Good:do' }]);
+ expect(result.skipped.unnamed).toStrictEqual([
+ 'string',
+ '{ type: "Anonymous:withAnUnusuallyLongDeclaration"; handler: (first: string, ...',
+ ]);
+ });
+ });
+
+ it('throws when a root union resolves to `any`', async () => {
+ expect.assertions(1);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+import type { Missing } from 'this-package-does-not-exist';
+
+export type GlobalActions = Missing;
+export type GlobalEvents = never;
+`,
+ );
+
+ expect(() =>
+ discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ }),
+ ).toThrow(
+ 'app/types.ts#GlobalActions, named by --root-actions, resolved to ' +
+ '`Missing` rather than a union of capabilities',
+ );
+ });
+ });
+
+ // TypeScript absorbs `any | T` into `any`, so one unresolved import would
+ // otherwise take every sibling capability down with it — silently, since the
+ // events union still resolves and generation would report success.
+ it('throws rather than dropping the siblings of an unresolved member', async () => {
+ expect.assertions(1);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+import type { Missing } from 'this-package-does-not-exist';
+
+export type GoodOne = { type: 'Good:one'; handler: () => void };
+export type GoodTwo = { type: 'Good:two'; handler: () => void };
+export type SomeEvent = { type: 'Good:changed'; payload: [string] };
+
+export type GlobalActions = GoodOne | GoodTwo | Missing;
+export type GlobalEvents = SomeEvent;
+`,
+ );
+
+ expect(() =>
+ discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ }),
+ ).toThrow(
+ // The whole union has become plain `any` — `GoodOne` and `GoodTwo` are
+ // no longer visible to the checker at all.
+ 'app/types.ts#GlobalActions, named by --root-actions, resolved to ' +
+ '`any` rather than a union of capabilities',
+ );
+ });
+ });
+
+ // The lone-instantiation fallback must not fire for a union: an anonymous
+ // member is genuinely anonymous, and blaming the wrapper type would file it
+ // under the wrong bucket with a misleading name.
+ it('reports an inline member as unnamed when the root aliases a union', async () => {
+ expect.assertions(3);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+export type NamedAction = { type: 'Named:do'; handler: () => void };
+
+type AllActions = NamedAction | { type: 'Anonymous:do'; handler: () => void };
+
+export type GlobalActions = AllActions;
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([{ typeString: 'Named:do' }]);
+ expect(result.skipped.unnamed).toStrictEqual([
+ '{ type: "Anonymous:do"; handler: () => void; }',
+ ]);
+ expect(result.skipped.unextractable).toStrictEqual([]);
+ });
+ });
+
+ it('reports a root that is a single inline capability as unnamed', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+export type GlobalActions = { type: 'Anonymous:do'; handler: () => void };
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toStrictEqual([]);
+ expect(result.skipped.unnamed).toStrictEqual([
+ '{ type: "Anonymous:do"; handler: () => void; }',
+ ]);
+ });
+ });
+
+ it('documents a lone generic instantiation of a type alias', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+export type GenericAction = {
+ type: 'Gen:do';
+ handler: (value: Value) => void;
+};
+
+export type GlobalActions = GenericAction;
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([
+ { typeName: 'GenericAction', typeString: 'Gen:do', kind: 'action' },
+ ]);
+ expect(result.skipped.unnamed).toStrictEqual([]);
+ });
+ });
+
+ it('documents a lone generic instantiation, whose alias symbol is the root union itself', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+export interface GenericAction {
+ type: 'Gen:do';
+ handler: (value: Value) => void;
+}
+
+export type GlobalActions = GenericAction;
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([
+ {
+ typeName: 'GenericAction',
+ typeString: 'Gen:do',
+ kind: 'action',
+ // The declaration's own text, so the type parameter is not
+ // substituted. Matches what `scan` produces for the same type.
+ handlerOrPayload: '(value: Value) => void',
+ },
+ ]);
+ expect(result.skipped.unextractable).toStrictEqual([]);
+ });
+ });
+
+ it('resolves capability types imported from another file', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/qux-controller.ts',
+ `
+/**
+ * Does the qux thing.
+ */
+export type QuxControllerDoAction = {
+ type: 'QuxController:do';
+ handler: () => void;
+};
+`,
+ );
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+import type { QuxControllerDoAction } from './qux-controller';
+
+export type GlobalActions = QuxControllerDoAction;
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([
+ {
+ typeString: 'QuxController:do',
+ jsDoc: 'Does the qux thing.',
+ sourceFile: path.join('app', 'qux-controller.ts'),
+ },
+ ]);
+ expect(result.skipped).toStrictEqual({
+ unnamed: [],
+ unextractable: [],
+ });
+ });
+ });
+
+ it('counts constituents that have no named declaration as unnamed', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+export type NamedAction = {
+ type: 'Named:do';
+ handler: () => void;
+};
+
+export type GlobalActions =
+ | NamedAction
+ | { type: 'Anonymous:do'; handler: () => void };
+
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([{ typeString: 'Named:do' }]);
+ expect(result.skipped.unnamed).toStrictEqual([
+ '{ type: "Anonymous:do"; handler: () => void; }',
+ ]);
+ });
+ });
+
+ it('counts capability types that cannot be extracted as unextractable', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+export type GoodAction = {
+ type: 'Good:do';
+ handler: () => void;
+};
+
+export type UnnamespacedAction = {
+ type: 'nocolon';
+ handler: () => void;
+};
+
+export type GlobalActions = GoodAction | UnnamespacedAction;
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([{ typeString: 'Good:do' }]);
+ expect(result.skipped.unextractable).toStrictEqual([
+ `UnnamespacedAction (${path.join('app', 'types.ts')}:7)`,
+ ]);
+ });
+ });
+
+ it('reads actions and events from different files', async () => {
+ expect.assertions(1);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/actions.ts',
+ `
+export type SplitDoAction = { type: 'Split:do'; handler: () => void };
+export type GlobalActions = SplitDoAction;
+`,
+ );
+ await writeFixture(
+ directoryPath,
+ 'app/events.ts',
+ `
+export type SplitDoneEvent = { type: 'Split:done'; payload: [string] };
+export type GlobalEvents = SplitDoneEvent;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/actions.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/events.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toMatchObject([
+ { typeString: 'Split:do', kind: 'action' },
+ { typeString: 'Split:done', kind: 'event' },
+ ]);
+ });
+ });
+
+ it('throws when the entry file does not exist', async () => {
+ expect.assertions(1);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ expect(() =>
+ discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/missing.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/missing.ts', typeName: 'GlobalEvents' },
+ }),
+ ).toThrow(
+ `Could not read ${path.join(directoryPath, 'app', 'missing.ts')}, which was named by --root-actions.`,
+ );
+ });
+ });
+
+ it('throws when the named type alias is not declared in the entry file', async () => {
+ expect.assertions(1);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `export type GlobalActions = never;\n`,
+ );
+
+ expect(() =>
+ discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'NotDeclared' },
+ }),
+ ).toThrow(
+ `No type alias named "NotDeclared" in ${path.join('app', 'types.ts')}, which was named by --root-events.`,
+ );
+ });
+ });
+
+ it('returns no packets when both root unions resolve to `never`', async () => {
+ expect.assertions(2);
+
+ await withinSandbox(async ({ directoryPath }) => {
+ await writeFixture(
+ directoryPath,
+ 'app/types.ts',
+ `
+export type GlobalActions = never;
+export type GlobalEvents = never;
+`,
+ );
+
+ const result = discoverFromRootMessenger({
+ projectPath: directoryPath,
+ actions: { filePath: 'app/types.ts', typeName: 'GlobalActions' },
+ events: { filePath: 'app/types.ts', typeName: 'GlobalEvents' },
+ });
+
+ expect(result.packets).toStrictEqual([]);
+ expect(result.skipped).toStrictEqual({
+ unnamed: [],
+ unextractable: [],
+ });
+ });
+ });
+});
diff --git a/packages/platform-api-docs/src/root-messenger-discovery.ts b/packages/platform-api-docs/src/root-messenger-discovery.ts
new file mode 100644
index 00000000000..f7cf9eb888a
--- /dev/null
+++ b/packages/platform-api-docs/src/root-messenger-discovery.ts
@@ -0,0 +1,359 @@
+import * as path from 'node:path';
+import type {
+ InterfaceDeclaration,
+ Project as TsMorphProject,
+ Type,
+ TypeAliasDeclaration,
+} from 'ts-morph';
+import { Node as NodeGuards, Project, ts } from 'ts-morph';
+
+import {
+ classifyMessengerCapabilityTypeDeclaration,
+ extractFromMessengerCapabilityTypeDeclaration,
+} from './extraction.js';
+import type { MessengerCapabilityPacket } from './types.js';
+
+// ---------------------------------------------------------------------------
+// The `root-messenger` strategy: resolve the unions a project declares for its
+// root messenger and let the type checker enumerate them. Going through the
+// checker rather than the AST means a union works whether it is written by
+// hand or computed (e.g. derived from a registry via `ReturnType<...>`). Each
+// capability it reports is handed to the shared extractor in `extraction.ts`,
+// so the output matches what the `scan` strategy produces.
+// ---------------------------------------------------------------------------
+
+/**
+ * A reference to a type in a file, written as `#`.
+ */
+export type RootTypeReference = {
+ /** Path to the declaring file, relative to the project root. */
+ filePath: string;
+ /** Name of the type alias within that file. */
+ typeName: string;
+};
+
+/**
+ * Options for {@link discoverFromRootMessenger}.
+ */
+type RootMessengerDiscoveryOptions = {
+ /** Absolute path to the project to scan. */
+ projectPath: string;
+ /** Type aliasing the union of every action on the root messenger. */
+ actions: RootTypeReference;
+ /** Type aliasing the union of every event on the root messenger. */
+ events: RootTypeReference;
+};
+
+/**
+ * Labels for capability types that were found but couldn't be documented,
+ * grouped by why. Labels rather than counts, so warnings can name what to fix.
+ */
+type SkippedCapabilities = {
+ /** Declared inline in the union, so there is no name or JSDoc to document. */
+ unnamed: string[];
+ /** Named, but of a shape the extractor rejects. */
+ unextractable: string[];
+};
+
+/**
+ * The result of {@link discoverFromRootMessenger}.
+ */
+type RootMessengerDiscoveryResult = {
+ /** Every capability extracted, actions before events. */
+ packets: MessengerCapabilityPacket[];
+ /** Capabilities that couldn't be documented. */
+ skipped: SkippedCapabilities;
+};
+
+/**
+ * Split a `#` reference into its parts, on the last `#` so
+ * that paths containing a `#` still work.
+ *
+ * @param reference - The raw reference, e.g. `src/messenger.ts#RootActions`.
+ * @returns The parsed reference.
+ * @throws If the reference has no `#`, or either side of it is empty.
+ */
+export function parseRootTypeReference(reference: string): RootTypeReference {
+ const separatorIndex = reference.lastIndexOf('#');
+ if (separatorIndex === -1) {
+ throw new Error(
+ `Expected a reference of the form "#", got "${reference}".`,
+ );
+ }
+
+ const filePath = reference.slice(0, separatorIndex);
+ const typeName = reference.slice(separatorIndex + 1);
+ if (filePath.length === 0 || typeName.length === 0) {
+ throw new Error(
+ `Expected a reference of the form "#", got "${reference}".`,
+ );
+ }
+
+ return { filePath, typeName };
+}
+
+/**
+ * Create a ts-morph Project for resolving root messenger types.
+ *
+ * No file list is loaded: this strategy opens only the entry files and lets
+ * the checker pull in the rest.
+ *
+ * @returns A new ts-morph Project.
+ */
+function createRootMessengerProject(): TsMorphProject {
+ return new Project({
+ compilerOptions: {
+ noEmit: true,
+ // We need symbol resolution, not full typechecking, so a project's own
+ // strictness settings shouldn't be able to fail the docs build.
+ strict: false,
+ skipLibCheck: true,
+ target: ts.ScriptTarget.ESNext,
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.NodeJs,
+ },
+ });
+}
+
+/**
+ * Resolve the type alias a reference names.
+ *
+ * @param project - The ts-morph project to load the file into.
+ * @param projectPath - Absolute path to the project root.
+ * @param reference - The reference to resolve.
+ * @param flagName - The CLI flag the reference came from, used in errors.
+ * @returns The type alias declaration.
+ * @throws If the file can't be read or declares no such type alias.
+ */
+function resolveRootDeclaration(
+ project: TsMorphProject,
+ projectPath: string,
+ reference: RootTypeReference,
+ flagName: string,
+): TypeAliasDeclaration {
+ const absolutePath = path.resolve(projectPath, reference.filePath);
+
+ let sourceFile;
+ try {
+ sourceFile =
+ project.getSourceFile(absolutePath) ??
+ project.addSourceFileAtPath(absolutePath);
+ } catch {
+ throw new Error(
+ `Could not read ${absolutePath}, which was named by ${flagName}.`,
+ );
+ }
+
+ const declaration = sourceFile.getTypeAlias(reference.typeName);
+ if (!declaration) {
+ throw new Error(
+ `No type alias named "${reference.typeName}" in ${reference.filePath}, which was named by ${flagName}.`,
+ );
+ }
+
+ return declaration;
+}
+
+/**
+ * Find the named declaration behind a union constituent.
+ *
+ * @param constituent - The resolved constituent type.
+ * @param rootDeclaration - The root union's own declaration.
+ * @param isLoneConstituent - Whether this is the root's only constituent.
+ * @returns The declaration, or undefined when the constituent is anonymous.
+ */
+function findCapabilityDeclaration(
+ constituent: Type,
+ rootDeclaration: TypeAliasDeclaration,
+ isLoneConstituent: boolean,
+): TypeAliasDeclaration | InterfaceDeclaration | undefined {
+ // Prefer the alias symbol: for a type alias it carries the name and JSDoc,
+ // where the plain symbol points at the anonymous object type. Skip an alias
+ // resolving back to the root union itself, which is what the checker reports
+ // for a lone generic instantiation such as `type Actions = Foo`.
+ const aliasDeclarations = (
+ constituent.getAliasSymbol()?.getDeclarations() ?? []
+ ).filter((node) => node !== rootDeclaration);
+
+ // An interface has no alias symbol, being its own declaration.
+ const declarations =
+ aliasDeclarations.length > 0
+ ? aliasDeclarations
+ : (constituent.getSymbol()?.getDeclarations() ?? []);
+
+ const found = declarations.find(
+ (node): node is TypeAliasDeclaration | InterfaceDeclaration =>
+ NodeGuards.isTypeAliasDeclaration(node) ||
+ NodeGuards.isInterfaceDeclaration(node),
+ );
+ if (found) {
+ return found;
+ }
+
+ // Nothing named behind the type itself. When the root aliases a single
+ // generic instantiation, the declaration we want is the one its type node
+ // references — `Foo` in `type Actions = Foo`. Only when it is the lone
+ // constituent, though: in a union, an anonymous member is genuinely
+ // anonymous, and attributing it to the wrapper would mislabel it.
+ return isLoneConstituent
+ ? findDeclarationFromRootTypeNode(rootDeclaration)
+ : undefined;
+}
+
+/**
+ * Resolve the declaration referenced by a root alias's type node.
+ *
+ * @param rootDeclaration - The root union's own declaration.
+ * @returns The referenced declaration, or undefined.
+ */
+function findDeclarationFromRootTypeNode(
+ rootDeclaration: TypeAliasDeclaration,
+): TypeAliasDeclaration | InterfaceDeclaration | undefined {
+ const typeNode = rootDeclaration.getTypeNode();
+ if (!typeNode || !NodeGuards.isTypeReference(typeNode)) {
+ return undefined;
+ }
+
+ const localSymbol = typeNode.getTypeName().getSymbol();
+ const symbol = localSymbol?.getAliasedSymbol() ?? localSymbol;
+ return symbol
+ ?.getDeclarations()
+ .find(
+ (node): node is TypeAliasDeclaration | InterfaceDeclaration =>
+ NodeGuards.isTypeAliasDeclaration(node) ||
+ NodeGuards.isInterfaceDeclaration(node),
+ );
+}
+
+/**
+ * Render a short, single-line label for an anonymous type.
+ *
+ * @param type - The type to describe.
+ * @param enclosingNode - Node to render the type relative to, so an aliased
+ * type reads as its name rather than `import("").Name`.
+ * @returns The label.
+ */
+function summarizeType(
+ type: Type,
+ enclosingNode: TypeAliasDeclaration,
+): string {
+ const text = type.getText(enclosingNode).replace(/\s+/gu, ' ');
+ return text.length > 80 ? `${text.slice(0, 77)}...` : text;
+}
+
+/**
+ * Extract every documentable capability from one root union.
+ *
+ * @param rootDeclaration - The root union's declaration.
+ * @param kind - Whether these are actions or events.
+ * @param projectPath - Absolute path to the project root.
+ * @param skipped - Labels collected as undocumentable constituents are found.
+ * @param reference - The reference that named this type, used in errors.
+ * @param flagName - The CLI flag the reference came from, used in errors.
+ * @returns The extracted capabilities.
+ * @throws If the union resolved to `any` or `unknown`.
+ */
+function extractFromRootType(
+ rootDeclaration: TypeAliasDeclaration,
+ kind: 'action' | 'event',
+ projectPath: string,
+ skipped: SkippedCapabilities,
+ reference: RootTypeReference,
+ flagName: string,
+): MessengerCapabilityPacket[] {
+ const rootType = rootDeclaration.getTypeNodeOrThrow().getType();
+
+ // TypeScript absorbs `any | T` into `any` and `unknown | T` into `unknown`,
+ // so a single member the checker can't resolve — typically a failed import —
+ // erases every other capability in the union. Fail instead of emitting a
+ // catalog that looks complete but silently isn't.
+ if (rootType.isAny() || rootType.isUnknown()) {
+ throw new Error(
+ `${reference.filePath}#${reference.typeName}, named by ${flagName}, ` +
+ `resolved to \`${rootType.getText()}\` rather than a union of ` +
+ `capabilities. This usually means an import in that file could not be ` +
+ `resolved; because TypeScript absorbs the rest of a union into ` +
+ `\`any\`, every other capability in it would be missing.`,
+ );
+ }
+
+ // A project with no capabilities of this kind aliases the union to `never`.
+ if (rootType.isNever()) {
+ return [];
+ }
+
+ const constituents = rootType.isUnion()
+ ? rootType.getUnionTypes()
+ : [rootType];
+ const packets: MessengerCapabilityPacket[] = [];
+
+ for (const constituent of constituents) {
+ const declaration = findCapabilityDeclaration(
+ constituent,
+ rootDeclaration,
+ constituents.length === 1,
+ );
+ if (!declaration) {
+ skipped.unnamed.push(summarizeType(constituent, rootDeclaration));
+ continue;
+ }
+
+ const classified = classifyMessengerCapabilityTypeDeclaration(
+ declaration,
+ kind,
+ );
+ const packet =
+ classified &&
+ extractFromMessengerCapabilityTypeDeclaration(classified, projectPath);
+ if (!packet) {
+ const sourceFile = declaration.getSourceFile().getFilePath();
+ skipped.unextractable.push(
+ `${declaration.getName()} (${path.relative(projectPath, sourceFile)}:${declaration.getStartLineNumber()})`,
+ );
+ continue;
+ }
+
+ packets.push(packet);
+ }
+
+ return packets;
+}
+
+/**
+ * Enumerate every action and event reachable from a project's root messenger.
+ *
+ * @param options - Discovery options.
+ * @returns The extracted capabilities plus anything skipped.
+ */
+export function discoverFromRootMessenger(
+ options: RootMessengerDiscoveryOptions,
+): RootMessengerDiscoveryResult {
+ const { projectPath, actions, events } = options;
+ const project = createRootMessengerProject();
+ const skipped: SkippedCapabilities = { unnamed: [], unextractable: [] };
+ const packets: MessengerCapabilityPacket[] = [];
+
+ for (const [reference, kind, flagName] of [
+ [actions, 'action', '--root-actions'],
+ [events, 'event', '--root-events'],
+ ] as const) {
+ const rootDeclaration = resolveRootDeclaration(
+ project,
+ projectPath,
+ reference,
+ flagName,
+ );
+ packets.push(
+ ...extractFromRootType(
+ rootDeclaration,
+ kind,
+ projectPath,
+ skipped,
+ reference,
+ flagName,
+ ),
+ );
+ }
+
+ return { packets, skipped };
+}