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..47fbbeada2a 100644
--- a/packages/platform-api-docs/README.md
+++ b/packages/platform-api-docs/README.md
@@ -35,12 +35,45 @@ 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 capabilities — the collection of every action and the collection of every event — and lets the TypeScript type checker walk 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 capability collection type (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..bd1d62a0ce3 100644
--- a/packages/platform-api-docs/src/cli.ts
+++ b/packages/platform-api-docs/src/cli.ts
@@ -7,6 +7,58 @@ import npmWhich from 'npm-which';
import yargs from 'yargs';
import { generate, resolveRepoUrl } from './generate.js';
+import type { RootCapabilitiesTypeReference } from './root-messenger-discovery.js';
+import { parseRootCapabilitiesTypeReference } from './root-messenger-discovery.js';
+
+/**
+ * Arguments shared by both discovery strategies.
+ */
+type CommonArguments = {
+ /** Whether to build the generated site. */
+ build: boolean;
+ /** Whether to serve the production build. */
+ serve: boolean;
+ /** Whether to serve the development site. */
+ dev: boolean;
+ /** Path to the project being documented. */
+ 'project-path': string;
+ /** Directory where generated documentation is written. */
+ output?: string;
+ /** Label displayed in the generated documentation. */
+ 'project-label'?: string;
+ /** URL where the generated site is served. */
+ 'site-url'?: string;
+ /** Base path where the generated site is served. */
+ 'site-base-url'?: string;
+};
+
+/**
+ * Arguments for the strategy that scans configured source directories.
+ */
+type ScanStrategyArguments = {
+ /** The selected strategy. */
+ strategy: 'scan';
+ /** Directories, relative to the project root, to scan. */
+ scanDir: string[];
+};
+
+/**
+ * Arguments for the strategy that resolves a root messenger's type unions.
+ */
+type RootMessengerStrategyArguments = {
+ /** The selected strategy. */
+ strategy: 'root-messenger';
+ /** The root messenger actions type reference. */
+ rootActions: RootCapabilitiesTypeReference;
+ /** The root messenger events type reference. */
+ rootEvents: RootCapabilitiesTypeReference;
+};
+
+/**
+ * Parsed CLI arguments, discriminated by the selected discovery strategy.
+ */
+type ParsedArguments = CommonArguments &
+ (ScanStrategyArguments | RootMessengerStrategyArguments);
/**
* Locate the Docusaurus binary in this package's `node_modules/.bin`. Using
@@ -120,10 +172,66 @@ async function resolveCommitSha(projectPath: string): Promise {
}
/**
- * Main CLI entry point.
+ * 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.
+ * @param argv.strategy - The selected discovery strategy.
+ * @param argv.rootActions - The parsed root actions type reference.
+ * @param argv.rootEvents - The parsed root events type reference.
+ * @param argv.scanDir - The additional directories to scan.
+ * @returns True when the combination is valid.
*/
-async function main(): Promise {
- const argv = await yargs(process.argv.slice(2))
+function checkStrategyArgs({
+ strategy,
+ rootActions,
+ rootEvents,
+ scanDir = [],
+}: {
+ strategy?: 'root-messenger' | 'scan';
+ rootActions?: RootCapabilitiesTypeReference;
+ rootEvents?: RootCapabilitiesTypeReference;
+ scanDir?: string[];
+}): ScanStrategyArguments | RootMessengerStrategyArguments {
+ if (strategy !== 'root-messenger') {
+ if (rootActions !== undefined || rootEvents !== undefined) {
+ throw new Error(
+ '--root-actions and --root-events only apply to --strategy root-messenger.',
+ );
+ }
+ return { strategy: 'scan', scanDir };
+ }
+
+ if (rootActions === undefined || rootEvents === undefined) {
+ throw new Error(
+ '--strategy root-messenger requires both --root-actions and --root-events, ' +
+ 'each written as "#".',
+ );
+ }
+ if (scanDir.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 { strategy, rootActions, rootEvents };
+}
+
+/**
+ * Parse and validate CLI arguments.
+ *
+ * @param argumentsForParsing - Arguments to parse, excluding the Node and
+ * script paths.
+ * @returns Parsed arguments, discriminated by discovery strategy.
+ */
+async function parseArguments(
+ argumentsForParsing: string[] = process.argv.slice(2),
+): Promise {
+ const argv = await yargs(argumentsForParsing)
.command(
'$0 [project-path]',
'Produces documentation for the platform API, the set of actions and events available in clients through the message bus.',
@@ -153,12 +261,31 @@ async function main(): Promise {
'Generate platform API docs and serve a development-only site',
default: false,
})
- .option('scan-dir', {
+ .option('strategy', {
+ type: 'string',
+ 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',
+ })
+ .choices('strategy', ['scan', 'root-messenger'] as const)
+ .option('root-actions', {
+ type: 'string',
+ coerce: parseRootCapabilitiesTypeReference,
+ description:
+ 'Type aliasing the union of every action on the root messenger, written as "#" (required with --strategy root-messenger)',
+ })
+ .option('root-events', {
type: 'string',
- array: true,
+ coerce: parseRootCapabilitiesTypeReference,
+ description:
+ 'Type aliasing the union of every event on the root messenger, written as "#" (required with --strategy root-messenger)',
+ })
+ .option('scan-dir', {
+ type: 'array',
+ string: true,
description:
'Additional directories within the project to scan for messenger actions and events (note: may be specified multiple times)',
- default: [] as string[],
+ default: [],
})
.option('output', {
type: 'string',
@@ -179,34 +306,71 @@ async function main(): Promise {
description:
'Path prefix the built site will be served under, e.g. /core/platform-api/',
})
- .help().argv;
+ .help()
+ .parse();
- const projectPathArg = argv['project-path'];
- const resolvedProjectPath = path.resolve(
- typeof projectPathArg === 'string' ? projectPathArg : '.',
- );
+ const strategyArguments = checkStrategyArgs(argv);
+
+ const projectPath = argv['project-path'];
+ if (typeof projectPath !== 'string') {
+ throw new Error('Expected --project-path to be a string.');
+ }
+
+ return {
+ build: argv.build,
+ serve: argv.serve,
+ dev: argv.dev,
+ 'project-path': projectPath,
+ output: argv.output,
+ 'project-label': argv['project-label'],
+ 'site-url': argv['site-url'],
+ 'site-base-url': argv['site-base-url'],
+ ...strategyArguments,
+ };
+}
+
+/**
+ * Main CLI entry point.
+ */
+async function main(): Promise {
+ const argv = await parseArguments();
+
+ const resolvedProjectPath = path.resolve(argv['project-path']);
const resolvedOutputDir = path.resolve(
argv.output ?? path.join(resolvedProjectPath, '.platform-api-docs'),
);
- const scanDirs = ['src', ...argv['scan-dir']].filter(
- (dir, index, dirs) => dirs.indexOf(dir) === index,
- );
const projectLabel =
- typeof argv['project-label'] === 'string' &&
- argv['project-label'].length > 0
+ argv['project-label'] && argv['project-label'].length > 0
? argv['project-label']
: null;
const commitSha = await resolveCommitSha(resolvedProjectPath);
const repoUrl = await resolveRepoUrl(resolvedProjectPath);
// Step 1: Generate docs
- await generate({
- projectPath: resolvedProjectPath,
- outputDir: resolvedOutputDir,
- scanDirs,
- projectLabel,
- commitSha,
- });
+ if (argv.strategy === 'root-messenger') {
+ await generate({
+ projectPath: resolvedProjectPath,
+ outputDir: resolvedOutputDir,
+ projectLabel,
+ commitSha,
+ strategy: 'root-messenger',
+ rootActions: argv.rootActions,
+ rootEvents: argv.rootEvents,
+ });
+ } else {
+ const scanDirs = ['src', ...argv.scanDir].filter(
+ (dir, index, dirs) => dirs.indexOf(dir) === index,
+ );
+
+ await generate({
+ projectPath: resolvedProjectPath,
+ outputDir: resolvedOutputDir,
+ projectLabel,
+ commitSha,
+ strategy: 'scan',
+ scanDirs,
+ });
+ }
// Step 2: If --build, --serve, or --dev, set up and run Docusaurus
if (argv.build || argv.serve || argv.dev) {
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..fcf55d9bed1 100644
--- a/packages/platform-api-docs/src/generate.test.ts
+++ b/packages/platform-api-docs/src/generate.test.ts
@@ -56,6 +56,7 @@ export type FooControllerMessenger = Messenger<'FooController', FooControllerGet
const result = await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -94,6 +95,7 @@ export type BarMessenger = Messenger<'Bar', BarAction, never>;
await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -134,6 +136,7 @@ export type MyMessenger = Messenger<'My', MyGetAction, never>;
const result = await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -169,6 +172,7 @@ export type TestMessenger = Messenger<'Test', TestGetAction, never>;
const result = await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -209,6 +213,7 @@ export type LinkedMessenger = Messenger<'Linked', LinkedGetAction, never>;
const result = await generate({
projectPath: directoryPath,
outputDir: path.join(directoryPath, '.docs'),
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -226,6 +231,7 @@ export type LinkedMessenger = Messenger<'Linked', LinkedGetAction, never>;
generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['nonexistent'],
}),
).rejects.toThrow('No scannable directories found');
@@ -271,6 +277,7 @@ export type FooMessenger = Messenger<'Foo', FooAction, never>;
const result = await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -299,6 +306,7 @@ export type FooMessenger = Messenger<'Foo', FooAction, never>;
const result = await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -335,6 +343,7 @@ export type GitMessenger = Messenger<'Git', GitGetAction, never>;
await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -377,6 +386,7 @@ export type GitMessenger = Messenger<'Git', GitGetAction, never>;
await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
commitSha: 'abc1234',
});
@@ -424,6 +434,7 @@ export type OkMessenger = Messenger<'Ok', OkAction, never>;
const result = await generate({
projectPath: directoryPath,
outputDir: path.join(directoryPath, '.docs'),
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -457,6 +468,7 @@ export type EventsOnlyMessenger = Messenger<'EventsOnly', never, EventsOnlyChang
const result = await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -492,6 +504,7 @@ export type FirstMessenger = Messenger<'First', FirstAction, never>;
await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
const firstExists = await fs.promises
@@ -515,6 +528,7 @@ export type SecondMessenger = Messenger<'Second', SecondAction, never>;
await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
const stalePresent = await fs.promises
@@ -546,6 +560,7 @@ export type AlphaMessenger = Messenger<'Alpha', AlphaAction, never>;
await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -597,6 +612,7 @@ export type FooMessenger = Messenger<'Foo', FooAction, never>;
const result = await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -639,6 +655,7 @@ export type DupeMessenger = Messenger<'Dupe', never, DupeEvent>;
const result = await generate({
projectPath: directoryPath,
outputDir: path.join(directoryPath, '.docs'),
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -675,6 +692,7 @@ export type FooMessenger = Messenger<
await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -726,6 +744,7 @@ export type DupeMessenger = Messenger<'Dupe', DupeAction, never>;
const result = await generate({
projectPath: directoryPath,
outputDir: path.join(directoryPath, '.docs'),
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -766,6 +785,7 @@ export type SWMessenger = Messenger<'ServiceWorkerController', never, SWEvent>;
const result = await generate({
projectPath: directoryPath,
outputDir: path.join(directoryPath, '.docs'),
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -808,6 +828,7 @@ export type FooMessenger = Messenger<'Foo', FooAction, never>;
await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -846,6 +867,7 @@ export type NonGhMessenger = Messenger<'NonGh', NonGhGetAction, never>;
await generate({
projectPath: directoryPath,
outputDir,
+ strategy: 'scan',
scanDirs: ['src'],
});
@@ -903,3 +925,243 @@ 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 capability collections. 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`. It's likely that an " +
+ 'individual action or event type is also `Missing`',
+ );
+ } finally {
+ warn.mockRestore();
+ }
+ });
+ });
+});
diff --git a/packages/platform-api-docs/src/generate.ts b/packages/platform-api-docs/src/generate.ts
index 11d336c59dd..4babf56046d 100644
--- a/packages/platform-api-docs/src/generate.ts
+++ b/packages/platform-api-docs/src/generate.ts
@@ -15,8 +15,81 @@ import {
generateNamespacePage,
generateSidebars,
} from './markdown.js';
+import type { RootCapabilitiesTypeReference } from './root-messenger-discovery.js';
+import { discoverFromRootMessengerCapabilitiesTypes } from './root-messenger-discovery.js';
import type { MessengerCapabilityPacket, NamespaceGroup } from './types.js';
+/** How many skipped capability types to name before summarizing the rest. */
+const MAX_SKIPPED_SHOWN = 10;
+
+/**
+ * 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 = {
+ /** The selected strategy. */
+ strategy: 'scan';
+ /** Directories, relative to the project root, to scan. */
+ scanDirs: string[];
+};
+
+/**
+ * Options for the `root-messenger` strategy, which walks the types a
+ * project declares for its root messenger capabilities instead of scanning the
+ * entire repo. Used when one messenger carries every action and event.
+ */
+type RootMessengerStrategyOptions = {
+ /** The selected strategy. */
+ strategy: 'root-messenger';
+ /** The root messenger actions type reference. */
+ rootActions: RootCapabilitiesTypeReference;
+ /** The root messenger events type reference. */
+ rootEvents: RootCapabilitiesTypeReference;
+};
+
+/**
+ * Options for the generate function.
+ */
+export type GenerateOptions = {
+ /** Absolute path to the project to scan. */
+ projectPath: string;
+ /** Absolute path to the output directory for generated docs. */
+ outputDir: string;
+ /**
+ * Short label identifying the project the docs were generated from (e.g.
+ * "Core", "Extension"). Stamped in the index page title.
+ */
+ projectLabel?: string | null;
+ /**
+ * Git commit SHA the docs were generated from. Stamped in the index page
+ * intro so engineers know how current the site is.
+ */
+ commitSha?: string | null;
+} & (ScanStrategyOptions | RootMessengerStrategyOptions);
+
+/**
+ * Result returned by the generate function.
+ */
+export type GenerateResult = {
+ namespaces: number;
+ actions: number;
+ events: number;
+};
+
+/**
+ * The set of directories available to scan for messenger types, resolved from
+ * the project's filesystem layout.
+ */
+type ScanSources = {
+ /** User-configured scan dirs that exist on disk (relative to projectPath). */
+ scanDirs: string[];
+ /** Absolute path to `packages/` if it exists, otherwise null. */
+ packagesDir: string | null;
+ /** Absolute path to `node_modules/@metamask/` if it exists, otherwise null. */
+ nodeModulesDir: string | null;
+};
+
/**
* Compute a deduplication score for a messenger item, preferring items with
* JSDoc and from the "home" package whose name matches the namespace.
@@ -128,50 +201,6 @@ async function resolveRepoBaseUrl(
return `${repoUrl}/blob/${ref}/`;
}
-/**
- * Options for the generate function.
- */
-export type GenerateOptions = {
- /** Absolute path to the project to scan. */
- 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.
- */
- projectLabel?: string | null;
- /**
- * Git commit SHA the docs were generated from. Stamped in the index page
- * intro so engineers know how current the site is.
- */
- commitSha?: string | null;
-};
-
-/**
- * Result returned by the generate function.
- */
-export type GenerateResult = {
- namespaces: number;
- actions: number;
- events: number;
-};
-
-/**
- * The set of directories available to scan for messenger types, resolved from
- * the project's filesystem layout.
- */
-type ScanSources = {
- /** User-configured scan dirs that exist on disk (relative to projectPath). */
- scanDirs: string[];
- /** Absolute path to `packages/` if it exists, otherwise null. */
- packagesDir: string | null;
- /** Absolute path to `node_modules/@metamask/` if it exists, otherwise null. */
- nodeModulesDir: string | null;
-};
-
/**
* Discover which configured source locations actually exist on disk.
*
@@ -510,16 +539,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 +565,101 @@ export async function generate(
logScanPlan(sources);
- const allItems = await scanSources(projectPath, sources);
+ return await scanSources(projectPath, sources);
+}
+
+/**
+ * Using the project's root messenger capability collection types as
+ * entrypoints, collect the constituent individual capability types and package
+ * them so that they can be displayed within the documentation site.
+ *
+ * @param projectPath - The project root path.
+ * @param options - The root-messenger strategy options.
+ * @returns The extracted capabilities.
+ */
+function collectFromRootMessengerCapabilities(
+ 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 { capabilityPackets, skippedCapabilities } =
+ discoverFromRootMessengerCapabilitiesTypes({
+ projectPath,
+ rootActionsTypeReference: rootActions,
+ rootEventsTypeReference: 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',
+ skippedCapabilities.unnamedCapabilities,
+ );
+ warnSkipped(
+ 'whose shape could not be read',
+ skippedCapabilities.unextractableCapabilities,
+ );
+
+ // If both capability collection types resolve to nothing, it's 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 (capabilityPackets.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 collections carrying every ` +
+ `capability, and that their imports resolve.`,
+ );
+ }
+
+ return capabilityPackets;
+}
+
+/**
+ * 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'
+ ? collectFromRootMessengerCapabilities(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..05432ec5c11
--- /dev/null
+++ b/packages/platform-api-docs/src/root-messenger-discovery.test.ts
@@ -0,0 +1,939 @@
+import { createSandbox } from '@metamask/utils/node';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+
+import {
+ discoverFromRootMessengerCapabilitiesTypes,
+ parseRootCapabilitiesTypeReference,
+} 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;
+ __rootActionsTypeReference: Actions;
+ __rootEventsTypeReference: 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('parseRootCapabilitiesTypeReference', () => {
+ it('splits a "#" reference into its parts', () => {
+ expect(
+ parseRootCapabilitiesTypeReference('app/core/types.ts#GlobalActions'),
+ ).toStrictEqual({
+ filePath: 'app/core/types.ts',
+ typeName: 'GlobalActions',
+ });
+ });
+
+ it('keeps "#" characters that appear in the file path portion', () => {
+ expect(
+ parseRootCapabilitiesTypeReference('a#b/types.ts#GlobalActions'),
+ ).toStrictEqual({
+ filePath: 'a#b/types.ts',
+ typeName: 'GlobalActions',
+ });
+ });
+
+ it('throws when the reference has no "#" separator', () => {
+ expect(() =>
+ parseRootCapabilitiesTypeReference('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(() => parseRootCapabilitiesTypeReference('#GlobalActions')).toThrow(
+ 'Expected a reference of the form "#", got "#GlobalActions".',
+ );
+ });
+
+ it('throws when the type name portion is empty', () => {
+ expect(() =>
+ parseRootCapabilitiesTypeReference('app/core/types.ts#'),
+ ).toThrow(
+ 'Expected a reference of the form "#", got "app/core/types.ts#".',
+ );
+ });
+});
+
+describe('discoverFromRootMessengerCapabilitiesTypes', () => {
+ 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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toHaveLength(2);
+ expect(result.capabilityPackets[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.capabilityPackets[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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/messenger.ts',
+ typeName: 'RootMessengerActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/messenger.ts',
+ typeName: 'RootMessengerEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toMatchObject([
+ {
+ typeName: 'AccountOrderControllerUpdateAction',
+ typeString: 'AccountOrderController:updateAccountsList',
+ kind: 'action',
+ jsDoc: 'Updates the accounts list.',
+ },
+ {
+ typeName: 'AccountOrderControllerStateChangeEvent',
+ typeString: 'AccountOrderController:stateChange',
+ kind: 'event',
+ },
+ ]);
+ expect(result.skippedCapabilities).toStrictEqual({
+ unnamedCapabilities: [],
+ unextractableCapabilities: [],
+ });
+ });
+ });
+
+ 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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).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 capability collection 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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toMatchObject([
+ { typeString: 'Good:do' },
+ ]);
+ expect(result.skippedCapabilities.unnamedCapabilities).toStrictEqual([
+ 'string',
+ '{ type: "Anonymous:withAnUnusuallyLongDeclaration"; handler: (first: string, ...',
+ ]);
+ });
+ });
+
+ it('throws when a root capability collection 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(() =>
+ discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ }),
+ ).toThrow(
+ 'app/types.ts#GlobalActions, named by --root-actions, resolved to ' +
+ "`Missing`. It's likely that an individual action or event type " +
+ 'is also `Missing`',
+ );
+ });
+ });
+
+ // 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(() =>
+ discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ }),
+ ).toThrow(
+ // The whole collection 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`. It's likely that an individual action or event type is " +
+ 'also `any`',
+ );
+ });
+ });
+
+ // 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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toMatchObject([
+ { typeString: 'Named:do' },
+ ]);
+ expect(result.skippedCapabilities.unnamedCapabilities).toStrictEqual([
+ '{ type: "Anonymous:do"; handler: () => void; }',
+ ]);
+ expect(
+ result.skippedCapabilities.unextractableCapabilities,
+ ).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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toStrictEqual([]);
+ expect(result.skippedCapabilities.unnamedCapabilities).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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toMatchObject([
+ { typeName: 'GenericAction', typeString: 'Gen:do', kind: 'action' },
+ ]);
+ expect(result.skippedCapabilities.unnamedCapabilities).toStrictEqual([]);
+ });
+ });
+
+ it('documents a lone generic instantiation, whose alias symbol is the root capability collection 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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).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.skippedCapabilities.unextractableCapabilities,
+ ).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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toMatchObject([
+ {
+ typeString: 'QuxController:do',
+ jsDoc: 'Does the qux thing.',
+ sourceFile: path.join('app', 'qux-controller.ts'),
+ },
+ ]);
+ expect(result.skippedCapabilities).toStrictEqual({
+ unnamedCapabilities: [],
+ unextractableCapabilities: [],
+ });
+ });
+ });
+
+ 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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toMatchObject([
+ { typeString: 'Named:do' },
+ ]);
+ expect(result.skippedCapabilities.unnamedCapabilities).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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toMatchObject([
+ { typeString: 'Good:do' },
+ ]);
+ expect(
+ result.skippedCapabilities.unextractableCapabilities,
+ ).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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/actions.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/events.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).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(() =>
+ discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/missing.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ 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(() =>
+ discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ 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 capability collections 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 = discoverFromRootMessengerCapabilitiesTypes({
+ projectPath: directoryPath,
+ rootActionsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalActions',
+ },
+ rootEventsTypeReference: {
+ filePath: 'app/types.ts',
+ typeName: 'GlobalEvents',
+ },
+ });
+
+ expect(result.capabilityPackets).toStrictEqual([]);
+ expect(result.skippedCapabilities).toStrictEqual({
+ unnamedCapabilities: [],
+ unextractableCapabilities: [],
+ });
+ });
+ });
+});
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..ef4415a3adf
--- /dev/null
+++ b/packages/platform-api-docs/src/root-messenger-discovery.ts
@@ -0,0 +1,525 @@
+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 types a project declares for its
+// collection of root messenger actions and events and let TypeScript walk them.
+// Each capability type found is handed to the shared extractor in
+// `extraction.ts`, so the output matches what the `scan` strategy produces.
+// ---------------------------------------------------------------------------
+
+/**
+ * A reference to a type declared in a file, written as `#`.
+ *
+ * The `root-messenger` strategy takes two of these on the command line — one
+ * naming a collection of messenger action types, one naming a collection of
+ * messenger event types — and uses them to locate the type declarations to
+ * enumerate.
+ */
+export type RootCapabilitiesTypeReference = {
+ /** Path to the declaring file, relative to the project root. */
+ filePath: string;
+ /** Name of the type alias within that file. */
+ typeName: string;
+};
+
+/**
+ * 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 = {
+ /**
+ * Capabilities declared inline in the capability collection type, so there is
+ * no name or JSDoc to document.
+ */
+ unnamedCapabilities: string[];
+ /*
+ * Capabilities that are named, but of a shape the extractor rejects.
+ */
+ unextractableCapabilities: string[];
+};
+
+/**
+ * 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 parseRootCapabilitiesTypeReference(
+ reference: string,
+): RootCapabilitiesTypeReference {
+ 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,
+ },
+ });
+}
+
+/**
+ * A `#` string, passed from the command line, refers to an
+ * messenger actions or events collection type. This function reads the file and
+ * looks up the matching type alias.
+ *
+ * @param args - The arguments to this function.
+ * @param args.project - The ts-morph project to load the file into.
+ * @param args.projectPath - Absolute path to the project root.
+ * @param args.reference - The root capability collection type reference to
+ * resolve.
+ * @param args.commandLineOptionName - The command-line option the reference
+ * came from, used in errors.
+ * @returns The type alias declaration the reference names.
+ * @throws If the file can't be read or declares no such type alias.
+ */
+function resolveMessengerCapabilitiesTypeReference({
+ project,
+ projectPath,
+ reference,
+ commandLineOptionName,
+}: {
+ project: TsMorphProject;
+ projectPath: string;
+ reference: RootCapabilitiesTypeReference;
+ commandLineOptionName: 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 ${commandLineOptionName}.`,
+ );
+ }
+
+ // EXAMPLES:
+ // type RootMessengerActions = ...
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ // type RootMessengerEvents = ...
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ const declaration = sourceFile.getTypeAlias(reference.typeName);
+ if (!declaration) {
+ throw new Error(
+ `No type alias named "${reference.typeName}" in ${reference.filePath}, which was named by ${commandLineOptionName}.`,
+ );
+ }
+
+ return declaration;
+}
+
+/**
+ * Given the type of a messenger capability (e.g.
+ * `NetworkControllerAddNetworkAction`) as obtained from a collection of
+ * capability types (e.g. `RootMessengerActions` or `RootMessengerEvents`),
+ * locate the type declaration for that capability type.
+ *
+ * This is not as simple as following the type to its declaration, because both
+ * a collection of capability types and the capability type itself can have
+ * multiple representations. So there are three strategies for finding the type:
+ *
+ * 1. If the capability type was declared as a type alias (e.g. `type
+ * FooControllerSomeAction = { ... }`), then we need to use the symbol to
+ * find the declaration.
+ * 2. If the capability type was declared as an interface (e.g. `interface
+ * FooControllerSomeAction { ... }`), we don't need to do this; interfaces
+ * are their own declaration.
+ * 3. If the capability *collection* type is not a union but merely a type alias
+ * (e.g. `type RootMessengerActions = NetworkControllerAddNetworkAction`)
+ * then we follow the right-hand side of the type alias.
+ *
+ * When none of these find a type declaration, the capability is anonymous
+ * (e.g. an inline object type with no name to document) and `undefined` is
+ * returned, so the caller can record it as skipped rather than document it.
+ *
+ * @param capabilityType - The type of the individual capability to find the
+ * declaration for.
+ * @param capabilityCollectionTypeDeclaration - The declaration of the whole
+ * collection the capability came from (e.g. `type RootMessengerActions = ...`).
+ * @param isLoneConstituent - Whether the capability collection only includes
+ * one capability type.
+ * @returns The type alias or interface declaration for the capability, or
+ * `undefined` when the capability is anonymous.
+ */
+function findMessengerCapabilityTypeDeclaration(
+ capabilityType: Type,
+ capabilityCollectionTypeDeclaration: TypeAliasDeclaration,
+ isLoneConstituent: boolean,
+): TypeAliasDeclaration | InterfaceDeclaration | undefined {
+ // If we have a type alias, look for its symbol.
+ //
+ // EXAMPLE:
+ // type FooControllerSomeAction = { type: '...'; handler: () => void };
+ // ^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ // the alias symbol names the plain symbol points at this anonymous
+ // this declaration object
+ //
+ // But skip an alias that resolves back to the capability collection itself,
+ // which is what TypeScript reports for a lone generic instantiation.
+ //
+ // EXAMPLE:
+ // Here the alias symbol of the sole member is `Actions`, i.e.
+ // `capabilityCollectionTypeDeclaration`, which is not the capability we
+ // want to document:
+ //
+ // type Actions = Foo;
+ // ^^^^^^^ capabilityCollectionTypeDeclaration
+ const typeAliasDeclarations = (
+ capabilityType.getAliasSymbol()?.getDeclarations() ?? []
+ ).filter((node) => node !== capabilityCollectionTypeDeclaration);
+
+ // An interface has no alias symbol, being its own declaration, so fall back
+ // to the plain symbol to reach it.
+ // EXAMPLE:
+ // interface FooControllerSomeAction { type: '...'; handler: () => void }
+ // ^^^^^^^^^^^^^^^^^^^^^^^ reached via the plain symbol
+ const typeDeclarations =
+ typeAliasDeclarations.length > 0
+ ? typeAliasDeclarations
+ : (capabilityType.getSymbol()?.getDeclarations() ?? []);
+
+ // Of the declarations behind whichever symbol we used, pick the type alias or
+ // interface.
+ // EXAMPLES:
+ // type FooControllerSomeAction = { ... }
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ // interface FooControllerSomeAction { ... }
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ const foundTypeDeclaration = typeDeclarations.find(
+ (node): node is TypeAliasDeclaration | InterfaceDeclaration =>
+ NodeGuards.isTypeAliasDeclaration(node) ||
+ NodeGuards.isInterfaceDeclaration(node),
+ );
+ if (foundTypeDeclaration) {
+ return foundTypeDeclaration;
+ }
+
+ // If the capability collection type has only one constituent, we can safely
+ // assume it's a type alias. If, in this case, it's also generic, the
+ // declaration we want is the one its type node references, so follow it.
+ //
+ // EXAMPLE:
+ // type Actions = Foo;
+ // ^^^ follow this reference to its declaration
+ //
+ if (isLoneConstituent) {
+ return resolveGenericCapabilityCollectionTypeDeclaration(
+ capabilityCollectionTypeDeclaration,
+ );
+ }
+
+ // If, after all of this, the capability collection is a union with an
+ // anonymous constituent, we ignore it:
+ //
+ // EXAMPLE:
+ // type Actions = FooControllerSomeAction | { type: '...'; handler: ... };
+ // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+ return undefined;
+}
+
+/**
+ * Given a messenger capability collection with one constituent which is a
+ * generic capability type, follow that type to find its declaration.
+ *
+ * A type that is aliased to a generic type is a special case we need to handle,
+ * because it won't have a direct alias symbol; we need to pick out the type
+ * that acts as the "box" in the generic (e.g. the `Foo` in `type Actions =
+ * Foo`).
+ *
+ * @param capabilityCollectionTypeDeclaration - The declaration for a
+ * collection of capability types (e.g. `type RootMessengerActions = ...`).
+ * @returns The resolved sole capability type.
+ */
+function resolveGenericCapabilityCollectionTypeDeclaration(
+ capabilityCollectionTypeDeclaration: TypeAliasDeclaration,
+): TypeAliasDeclaration | InterfaceDeclaration | undefined {
+ // The root alias must reference another type by name for there to be a
+ // declaration to follow.
+ // EXAMPLE:
+ // type Actions = Foo;
+ // ^^^^^^^^
+ const typeNode = capabilityCollectionTypeDeclaration.getTypeNode();
+ if (!typeNode || !NodeGuards.isTypeReference(typeNode)) {
+ return undefined;
+ }
+
+ // Resolve the referenced name (e.g. `Foo` in `Foo`) to its symbol.
+ const localSymbol = typeNode.getTypeName().getSymbol();
+ // If the type is imported from another file, ensure that when we access the
+ // declaration, it's the type declaration in the other file, not the import
+ // declaration in this file.
+ // EXAMPLE:
+ // import { Foo } from '@metamask/foo';
+ // type Actions = Foo;
+ // ^^^
+ const symbol = localSymbol?.getAliasedSymbol() ?? localSymbol;
+
+ // Follow the reference to the type alias or interface it names.
+ // EXAMPLES:
+ // type Foo = { ... }
+ // ^^^^^^^^^^^^^^^^^^^^
+ // interface Foo { ... }
+ // ^^^^^^^^^^^^^^^^^^^^^^^
+ 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;
+}
+
+/**
+ * Walk a messenger capability collection type (e.g. `RootMessengerActions` or
+ * `RootMessengerEvents`) to gather all of the consitutent capability types
+ * (e.g. `NetworkControllerAddNetworkAction`), then package them so that they
+ * can be displayed within the documentation.
+ *
+ * Messenger capabilities that cannot be extracted for some reason are captured
+ * separately.
+ *
+ * @param args - The arguments to this function.
+ * @param args.projectPath - Absolute path to the project root.
+ * @param args.capabilityKind - Whether these are actions or events.
+ * @param args.capabilityCollectionTypeReference - A reference to a messenger
+ * capability collection type within the project, in `#` format.
+ * @param args.capabilityCollectionTypeDeclaration - The type declaration
+ * representing a collection of messenger capabilities.
+ * @param args.commandLineOptionName - The command-line option the reference
+ * came from, used in errors.
+ * @returns The extracted capabilities along with skipped capabilities.
+ * @throws If the capability collection type resolved to `any` or `unknown`.
+ */
+function extractFromMessengerCapabilitiesUnionTypeDeclaration({
+ projectPath,
+ capabilityKind,
+ capabilityCollectionTypeReference,
+ capabilityCollectionTypeDeclaration,
+ commandLineOptionName,
+}: {
+ projectPath: string;
+ capabilityKind: 'action' | 'event';
+ capabilityCollectionTypeReference: RootCapabilitiesTypeReference;
+ capabilityCollectionTypeDeclaration: TypeAliasDeclaration;
+ commandLineOptionName: string;
+}): {
+ capabilityPackets: MessengerCapabilityPacket[];
+ skippedCapabilities: SkippedCapabilities;
+} {
+ const capabilityCollectionType = capabilityCollectionTypeDeclaration
+ .getTypeNodeOrThrow()
+ .getType();
+
+ // If one or more of the constituent capabilities in the collection is `any`
+ // or `unknown` — e.g. its import failed — then the type of the whole
+ // collection will also be `any` or `unknown`. Fail instead of emitting a
+ // catalog that looks complete but silently isn't.
+ if (
+ capabilityCollectionType.isAny() ||
+ capabilityCollectionType.isUnknown()
+ ) {
+ throw new Error(
+ `${capabilityCollectionTypeReference.filePath}#${capabilityCollectionTypeReference.typeName}, named by ${commandLineOptionName}, ` +
+ `resolved to \`${capabilityCollectionType.getText()}\`. ` +
+ `It's likely that an individual action or event type is also \`${capabilityCollectionType.getText()}\`, ` +
+ `which may be due to a failed import. ` +
+ `You will need to fix this first before generating docs for this project.`,
+ );
+ }
+
+ const skippedCapabilities: SkippedCapabilities = {
+ unnamedCapabilities: [],
+ unextractableCapabilities: [],
+ };
+
+ // A project with no capabilities of this kind aliases the union to `never`.
+ if (capabilityCollectionType.isNever()) {
+ return {
+ capabilityPackets: [],
+ skippedCapabilities,
+ };
+ }
+
+ const individualCapabilityTypes = capabilityCollectionType.isUnion()
+ ? capabilityCollectionType.getUnionTypes()
+ : [capabilityCollectionType];
+ const capabilityPackets: MessengerCapabilityPacket[] = [];
+
+ for (const capabilityType of individualCapabilityTypes) {
+ const capabilityTypeDeclaration = findMessengerCapabilityTypeDeclaration(
+ capabilityType,
+ capabilityCollectionTypeDeclaration,
+ individualCapabilityTypes.length === 1,
+ );
+ if (!capabilityTypeDeclaration) {
+ skippedCapabilities.unnamedCapabilities.push(
+ summarizeType(capabilityType, capabilityCollectionTypeDeclaration),
+ );
+ continue;
+ }
+
+ const classifiedTypeDeclaration =
+ classifyMessengerCapabilityTypeDeclaration(
+ capabilityTypeDeclaration,
+ capabilityKind,
+ );
+ const capabilityPacket =
+ classifiedTypeDeclaration &&
+ extractFromMessengerCapabilityTypeDeclaration(
+ classifiedTypeDeclaration,
+ projectPath,
+ );
+ if (!capabilityPacket) {
+ const sourceFile = capabilityTypeDeclaration
+ .getSourceFile()
+ .getFilePath();
+ skippedCapabilities.unextractableCapabilities.push(
+ `${capabilityTypeDeclaration.getName()} (${path.relative(projectPath, sourceFile)}:${capabilityTypeDeclaration.getStartLineNumber()})`,
+ );
+ continue;
+ }
+
+ capabilityPackets.push(capabilityPacket);
+ }
+
+ return { capabilityPackets, skippedCapabilities };
+}
+
+/**
+ * Resolves `#` references to messenger actions and events
+ * collection types within the given project (e.g. `RootMessengerActions` or
+ * `RootMessengerEvents`), walks the collection to gather all of the containing
+ * capability types (e.g. `NetworkControllerAddNetworkAction`), then packages
+ * them so that they can be displayed within the documentation site.
+ *
+ * @param args - The arguments to this function.
+ * @param args.projectPath -Absolute path to the project to scan.
+ * @param args.rootActionsTypeReference - A reference to a messenger
+ * actions collection type within the project, in `#` format.
+ * @param args.rootEventsTypeReference - A reference to a messenger events
+ * collection type within the project, in `#` format.
+ * @returns The extracted capabilities plus any capabilities that were skipped.
+ */
+export function discoverFromRootMessengerCapabilitiesTypes({
+ projectPath,
+ rootActionsTypeReference,
+ rootEventsTypeReference,
+}: {
+ projectPath: string;
+ rootActionsTypeReference: RootCapabilitiesTypeReference;
+ rootEventsTypeReference: RootCapabilitiesTypeReference;
+}): {
+ capabilityPackets: MessengerCapabilityPacket[];
+ skippedCapabilities: SkippedCapabilities;
+} {
+ const project = createRootMessengerProject();
+ const capabilityPacketCollections = [
+ [rootActionsTypeReference, 'action', '--root-actions'],
+ [rootEventsTypeReference, 'event', '--root-events'],
+ ] as const;
+ const allCapabilityPackets: MessengerCapabilityPacket[] = [];
+ const allSkippedCapabilities: SkippedCapabilities = {
+ unnamedCapabilities: [],
+ unextractableCapabilities: [],
+ };
+
+ for (const [
+ capabilitiesCollectionTypeReference,
+ kind,
+ commandLineOptionName,
+ ] of capabilityPacketCollections) {
+ const capabilitiesCollectionTypeDeclaration =
+ resolveMessengerCapabilitiesTypeReference({
+ project,
+ projectPath,
+ reference: capabilitiesCollectionTypeReference,
+ commandLineOptionName,
+ });
+ const { capabilityPackets, skippedCapabilities } =
+ extractFromMessengerCapabilitiesUnionTypeDeclaration({
+ projectPath,
+ capabilityKind: kind,
+ capabilityCollectionTypeReference: capabilitiesCollectionTypeReference,
+ capabilityCollectionTypeDeclaration:
+ capabilitiesCollectionTypeDeclaration,
+ commandLineOptionName,
+ });
+ allCapabilityPackets.push(...capabilityPackets);
+ allSkippedCapabilities.unnamedCapabilities.push(
+ ...skippedCapabilities.unnamedCapabilities,
+ );
+ allSkippedCapabilities.unextractableCapabilities.push(
+ ...skippedCapabilities.unextractableCapabilities,
+ );
+ }
+
+ return {
+ capabilityPackets: allCapabilityPackets,
+ skippedCapabilities: allSkippedCapabilities,
+ };
+}