Skip to content

Commit a949073

Browse files
cryptodev-2smcmire
andauthored
feat(platform-api-docs): add root-messenger strategy (#9913)
## Explanation `@metamask/platform-api-docs` documents the platform API — every messenger action and event a project exposes. Until now it had one way of finding them: parse every TypeScript source and declaration file it can reach (the scan directories, `packages/*/src`, and `node_modules/@metamask/*/dist/**/*.d.cts`) and walk every type alias named `*Messenger`. That is the right approach for this monorepo, which has no single messenger aggregating every capability. It is a poor fit for a client, which already declares the complete set on its root messenger. Re-deriving that from the whole dependency tree means parsing ~11,600 files in `metamask-mobile` and ~4,500 in `metamask-extension`, to rediscover something the client has written down in one place. This PR adds a second strategy that reads what the client already declares. ### `--strategy` - **`scan`** (default) — unchanged behaviour, and the only option for a project with no single aggregating messenger. - **`root-messenger`** — resolves the two types named by `--root-actions` and `--root-events` (each written `<file>#<TypeName>`) and lets the TypeScript type checker enumerate them. Only the named files are opened; the checker pulls in the rest. Flags belonging to the strategy that wasn't selected are rejected rather than ignored, via a yargs `.check`, so a mistaken invocation fails loudly instead of quietly producing docs built the wrong way. The `<file>#<TypeName>` references are parsed in a yargs `.coerce`, so a malformed one is reported like any other bad argument before work begins. ### Why the type checker rather than the AST This is the non-obvious part. The two clients declare their root unions differently: - `metamask-mobile` writes `GlobalActions` by hand as a union of type references. A syntactic walk would work. - `metamask-extension` derives `RootMessengerActions` from a registry of messenger factories via `MessengerActions<ReturnType<(typeof MESSENGER_FACTORIES)[…]['getMessenger']>>`. **There is no syntactic union to walk** — only the type checker can say what it contains. Going through the checker handles both shapes with one code path. Once it reports *which* capability types are in the union, each declaration is handed to the **existing** extractor in `extraction.ts`, so JSDoc, handler/payload signatures, source links, and deprecation flags come out identical to `scan`. The new module is a discovery front-end, not a second extractor. Two details worth knowing: - A capability declared as a **type alias** carries its name and JSDoc on the *alias* symbol; the plain symbol points at the anonymous object type. An **interface** has no alias symbol, being its own declaration, so both are consulted. Missing the interface case silently dropped 61 actions and 8 events on mobile before it was fixed. - For a lone generic instantiation (`type Actions = Foo<Bar>`) the checker attributes the alias to the *root union itself*, which would hand the extractor the wrong declaration. That case is guarded. ### Failure behaviour Generation now fails loudly instead of producing an empty site. `writeOutput` deletes `docs/` before writing, so a root union that resolves to nothing — a renamed type, or imports that don't resolve — would previously have replaced a published docs directory with an empty one and exited `0`. It now throws, naming both references. Capability types that can't be documented are reported with their names rather than counted, in three buckets: declared inline (no name or JSDoc), unresolved (`any`/`unknown`, usually a failed import), and unextractable (a shape the extractor rejects). A count alone isn't actionable at this scale. ### Also fixed: MDX escaping `escapeJsDocTextForMdx` escaped `{` and `}` but not `<`, which MDX reads as the start of a JSX tag. A `@returns` comment such as `Promise<PointsBoostDto[]>` therefore **failed the site build** rather than rendering: ``` Unexpected character `[` (U+005B) in name, expected a name character… ``` This is pre-existing and independent of the new strategy — `scan` produces the byte-identical line — but it blocked `--build` and `--serve` for both clients, so it is fixed here. Affects description, `@param`, and `@returns` text; handler and payload signatures were already safe inside fenced code blocks. --- ## Benchmarks Measured on a warm checkout, doc generation only. | Project | `scan` | `root-messenger` | Speedup | | --- | --- | --- | --- | | `metamask-extension` | 47.9s | **4.7s** | ~10× | | `metamask-mobile` | 96.9s | **5.7s** | ~17× | `scan` parses ~5,600 `app/**/*.ts` plus ~6,026 `.d.cts` in mobile, and ~4,472 `.d.cts` in the extension. `root-messenger` opens the entry file and lets the checker pull in only what the union references. ## Strategy comparison ### `metamask-mobile` | | `scan` | `root-messenger` | | --- | --- | --- | | Namespaces | 112 | 103 | | Actions | 1184 | 1157 | | Events | 181 | 171 | | Unique capabilities | 1365 | 1328 (97.3%) | The 37 not documented are mostly controllers genuinely **not on the root messenger** — `PasskeyController` alone accounts for 17, plus `RatesController` (4), the sample controllers, and the decrypt/encrypt message managers. Nothing is found by `root-messenger` that `scan` misses. ### `metamask-extension` | | `scan` | `root-messenger` | | --- | --- | --- | | Namespaces | 119 | 115 | | Actions | 1209 | 1085 | | Events | 183 | 176 | | Unique capabilities | 1392 | 1261 (90.6%) | 99 of the 131-capability gap is `PerpsController`, and the cause is worth flagging to the extension team rather than treating as a tool limitation: ```ts export type PerpsControllerMessenger = Messenger< 'PerpsController', AllowedActions, // actions Perps may CALL AllowedEvents >; ``` `RootMessengerActions` is `MessengerActions<ChildMessengers>` — the union of what each child messenger is **allowed to call**, not what each controller **provides**. A controller whose actions are only invoked from the UI, never from another controller's messenger, never appears. `PerpsController` is registered in `MESSENGER_FACTORIES` yet contributes **zero** constituents. `root-messenger` documents exactly what the named types contain. Full coverage in. the extension needs an aggregate of *provided* actions, which is an extension-side change. Conversely, `root-messenger` finds 6 capabilities `scan` misses(`MultichainRoutingService` ×4, `PPOMController` ×2). Reported-but-skipped, current run: mobile 39 unextractable; extension 2 inline + 33 unextractable. --- ## Usage in clients Once published, add the dependency and two scripts. For `metamask-mobile`: ```json { "scripts": { "docs:platform-api:build": "platform-api-docs --build --project-label Mobile --strategy root-messenger --root-actions 'app/core/Engine/types.ts#GlobalActions' --root-events 'app/core/Engine/types.ts#GlobalEvents'", "docs:platform-api:serve": "platform-api-docs --serve --project-label Mobile --site-base-url / --strategy root-messenger --root-actions 'app/core/Engine/types.ts#GlobalActions' --root-events 'app/core/Engine/types.ts#GlobalEvents'" } } ``` For `metamask-extension`, the label and references change: ``` --project-label Extension --root-actions 'app/scripts/lib/messenger.ts#RootMessengerActions' --root-events 'app/scripts/lib/messenger.ts#RootMessengerEvents' ``` Notes for consumers: - Keep the `#` **inside quotes** — unquoted, most shells treat it as a comment and silently truncate the argument. - `--root-actions` / `--root-events` are relative to the project path, not the shell's working directory. - Output defaults to `<project-path>/.platform-api-docs`; gitignore it. - `metamask-extension` additionally needs its `postcss-loader/jiti` resolution narrowed to `postcss-loader@^8.2.1/jiti`. The unversioned form also stubs the `postcss-loader@^7.3.4` that `@docusaurus/bundler` depends on, replacing `jiti` with an empty package and breaking the site build. Narrowing preserves the stub's original intent for the extension's own `postcss-loader@8.2.1`. ## References Fixes: https://consensyssoftware.atlassian.net/browse/WPC-1202 * Consumer PR (mobile): MetaMask/metamask-mobile#26526 * Consumer PR (extension, includes the `jiti` resolution fix): MetaMask/metamask-extension#40352 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Changes are confined to the docs CLI and generation pipeline; default `scan` behavior is preserved with an explicit `strategy` field in tests. > > **Overview** > Adds a **`root-messenger`** discovery path alongside the existing **`scan`** default: the CLI accepts `--strategy`, `--root-actions`, and `--root-events` (`<file>#<TypeName>`), validates that strategy-specific flags are not mixed, and routes generation through type-checker resolution of the project’s root action/event unions instead of scanning the whole tree. > > New **`root-messenger-discovery`** walks those unions (including checker-derived unions like `MessengerActions<…>`), reuses the shared extractor in **`extraction.ts`** (with **`classifyMessengerCapabilityTypeDeclaration`** exported for reuse), warns on skipped inline or unextractable capabilities, and **throws** when unions resolve to `any`/`unknown` or would produce zero docs so an empty site cannot overwrite published output. > > **`escapeJsDocTextForMdx`** now escapes `<` as well as braces so generic types in JSDoc do not break MDX builds. README, changelog, and broad CLI/generate/discovery tests cover the new behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1da2bbd. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Elliot Winkler <elliot.winkler@gmail.com>
1 parent 8e61b10 commit a949073

10 files changed

Lines changed: 2414 additions & 123 deletions

File tree

packages/platform-api-docs/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- Initial release of the platform-api-docs package ([#8012](https://github.com/MetaMask/core/pull/8012))
12+
- Initial release of the platform-api-docs package ([#8012](https://github.com/MetaMask/core/pull/8012), [#9913](https://github.com/MetaMask/core/pull/9913))
1313

1414
[Unreleased]: https://github.com/MetaMask/core/

packages/platform-api-docs/README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,45 @@ Options:
3535
--build Generate docs and build static site
3636
--serve Generate docs, build, and serve static site
3737
--dev Generate docs and start dev server with hot reload
38-
--scan-dir <dir> Extra source directory to scan (repeatable)
38+
--strategy <name> How to find actions and events: "scan" (default) or
39+
"root-messenger" (see below)
40+
--scan-dir <dir> Extra source directory to scan (repeatable; --strategy scan only)
41+
--root-actions <ref> Type aliasing the union of every action, as "<file>#<TypeName>"
42+
(required with --strategy root-messenger)
43+
--root-events <ref> Type aliasing the union of every event, as "<file>#<TypeName>"
44+
(required with --strategy root-messenger)
3945
--output <dir> Output directory (default: <project-path>/.platform-api-docs)
4046
--project-label <label> Short label identifying the project (e.g. "Core", "Extension")
4147
--help Show this help message
4248
```
4349

50+
## Strategies
51+
52+
Which strategy to use depends on whether the project has a single messenger carrying every action and event.
53+
54+
### `scan` (default)
55+
56+
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.
57+
58+
Use this when no single messenger aggregates every capability, as in a monorepo of independently published packages.
59+
60+
### `root-messenger`
61+
62+
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.
63+
64+
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.
65+
66+
```
67+
platform-api-docs \
68+
--strategy root-messenger \
69+
--root-actions 'src/messenger.ts#RootActions' \
70+
--root-events 'src/messenger.ts#RootEvents'
71+
```
72+
73+
Each reference names a type alias, written by hand or computed — the type checker resolves either.
74+
75+
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).
76+
4477
## Contributing
4578

4679
This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme).

packages/platform-api-docs/src/cli.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,175 @@ export type QuxMessenger = Messenger<'Qux', QuxAction, never>;
164164
expect(result.all).toContain('No scannable directories found');
165165
});
166166
});
167+
168+
describe('--strategy root-messenger', () => {
169+
/**
170+
* Write a project whose root messenger unions live in `app/types.ts`.
171+
*
172+
* @param directoryPath - The sandbox root.
173+
*/
174+
async function writeRootMessengerProject(
175+
directoryPath: string,
176+
): Promise<void> {
177+
const appDir = path.join(directoryPath, 'app');
178+
await fs.promises.mkdir(appDir, { recursive: true });
179+
await fs.promises.writeFile(
180+
path.join(appDir, 'types.ts'),
181+
`
182+
export type FooControllerGetStateAction = {
183+
type: 'FooController:getState';
184+
handler: () => FooState;
185+
};
186+
187+
export type FooControllerStateChangeEvent = {
188+
type: 'FooController:stateChange';
189+
payload: [FooState, Patch[]];
190+
};
191+
192+
export type GlobalActions = FooControllerGetStateAction;
193+
export type GlobalEvents = FooControllerStateChangeEvent;
194+
`,
195+
);
196+
}
197+
198+
it('generates docs from the named root messenger unions', async () => {
199+
expect.assertions(3);
200+
201+
await withinSandbox(async ({ directoryPath }) => {
202+
await writeRootMessengerProject(directoryPath);
203+
204+
const result = await runCLI([
205+
directoryPath,
206+
'--strategy',
207+
'root-messenger',
208+
'--root-actions',
209+
'app/types.ts#GlobalActions',
210+
'--root-events',
211+
'app/types.ts#GlobalEvents',
212+
]);
213+
214+
expect(result.exitCode).toBe(0);
215+
expect(result.all).toContain('Found 2 messenger items total');
216+
expect(result.all).toContain('Generated docs for 1 namespace');
217+
});
218+
});
219+
220+
it('exits with error when the root type references are missing', async () => {
221+
expect.assertions(2);
222+
223+
await withinSandbox(async ({ directoryPath }) => {
224+
await writeRootMessengerProject(directoryPath);
225+
226+
const result = await runCLI([
227+
directoryPath,
228+
'--strategy',
229+
'root-messenger',
230+
]);
231+
232+
expect(result.exitCode).not.toBe(0);
233+
expect(result.all).toContain(
234+
'requires both --root-actions and --root-events',
235+
);
236+
});
237+
});
238+
239+
it('exits with error when a root type reference is malformed', async () => {
240+
expect.assertions(2);
241+
242+
await withinSandbox(async ({ directoryPath }) => {
243+
await writeRootMessengerProject(directoryPath);
244+
245+
const result = await runCLI([
246+
directoryPath,
247+
'--strategy',
248+
'root-messenger',
249+
'--root-actions',
250+
'app/types.ts',
251+
'--root-events',
252+
'app/types.ts#GlobalEvents',
253+
]);
254+
255+
expect(result.exitCode).not.toBe(0);
256+
expect(result.all).toContain(
257+
'Expected a reference of the form "<file>#<TypeName>"',
258+
);
259+
});
260+
});
261+
262+
it('exits with error when the named type is not declared', async () => {
263+
expect.assertions(2);
264+
265+
await withinSandbox(async ({ directoryPath }) => {
266+
await writeRootMessengerProject(directoryPath);
267+
268+
const result = await runCLI([
269+
directoryPath,
270+
'--strategy',
271+
'root-messenger',
272+
'--root-actions',
273+
'app/types.ts#NotDeclared',
274+
'--root-events',
275+
'app/types.ts#GlobalEvents',
276+
]);
277+
278+
expect(result.exitCode).not.toBe(0);
279+
expect(result.all).toContain('No type alias named "NotDeclared"');
280+
});
281+
});
282+
283+
it('exits with error when --scan-dir is combined with it', async () => {
284+
expect.assertions(2);
285+
286+
await withinSandbox(async ({ directoryPath }) => {
287+
await writeRootMessengerProject(directoryPath);
288+
289+
const result = await runCLI([
290+
directoryPath,
291+
'--strategy',
292+
'root-messenger',
293+
'--root-actions',
294+
'app/types.ts#GlobalActions',
295+
'--root-events',
296+
'app/types.ts#GlobalEvents',
297+
'--scan-dir',
298+
'app',
299+
]);
300+
301+
expect(result.exitCode).not.toBe(0);
302+
expect(result.all).toContain(
303+
'--scan-dir only applies to --strategy scan',
304+
);
305+
});
306+
});
307+
308+
it('exits with error when root type references are used with --strategy scan', async () => {
309+
expect.assertions(2);
310+
311+
await withinSandbox(async ({ directoryPath }) => {
312+
await writeRootMessengerProject(directoryPath);
313+
314+
const result = await runCLI([
315+
directoryPath,
316+
'--root-actions',
317+
'app/types.ts#GlobalActions',
318+
]);
319+
320+
expect(result.exitCode).not.toBe(0);
321+
expect(result.all).toContain(
322+
'--root-actions and --root-events only apply to --strategy root-messenger',
323+
);
324+
});
325+
});
326+
327+
it('rejects an unknown strategy', async () => {
328+
expect.assertions(2);
329+
330+
await withinSandbox(async ({ directoryPath }) => {
331+
const result = await runCLI([directoryPath, '--strategy', 'telepathy']);
332+
333+
expect(result.exitCode).not.toBe(0);
334+
expect(result.all).toContain('Invalid values');
335+
});
336+
});
337+
});
167338
});

0 commit comments

Comments
 (0)