From 57ad0bd3313638b45aab90ecaa836a452d977bc9 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Thu, 21 May 2026 14:35:55 -0600 Subject: [PATCH] Refactor/simplify extraction.ts The goal of this commit is to make `extraction.ts` easier to follow. Since we are working with the TypeScript AST, we have to accept that the code will be somewhat dense, and we will need to ask maintainers to do some amount of studying to develop a mental model in their head. However the more we can do to assist this process the better. To this end, this commit: - Adds comments to illustrate steps in the parsing process and underlying nodes in the AST that are being visited - Renames some functions to more accurately describe their contents - Removes unnecessary `istanbul ignore` comments in favor of either adding tests that exercise the underlying logic or using assumptions (non-null assertions). - Rewrite some JSDoc so it's less technical and more natural sounding --- .../platform-api-docs/src/extraction.test.ts | 376 ++++++++ packages/platform-api-docs/src/extraction.ts | 832 ++++++++++-------- packages/platform-api-docs/src/generate.ts | 20 +- packages/platform-api-docs/src/markdown.ts | 4 +- packages/platform-api-docs/src/types.ts | 10 +- 5 files changed, 862 insertions(+), 380 deletions(-) diff --git a/packages/platform-api-docs/src/extraction.test.ts b/packages/platform-api-docs/src/extraction.test.ts index f861f41a48..61c3fe7899 100644 --- a/packages/platform-api-docs/src/extraction.test.ts +++ b/packages/platform-api-docs/src/extraction.test.ts @@ -1438,6 +1438,32 @@ export type SecondMessenger = Messenger<'Second', SharedAction, never>; }); }); + it('skips an interface that is referenced by a Messenger but has no `type` property', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // An interface that is missing the `type` property cannot produce a type + // string. The literal extractor returns null, and the constructor + // extractor also returns null (because it only handles type aliases). + await fs.promises.writeFile( + filePath, + withMessenger( + ` +export interface FooAction { + handler: () => void; +} +`, + { actions: ['FooAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toStrictEqual([]); + }); + }); + it('ignores capability-type-constructor aliases whose name does not match', async () => { expect.assertions(1); @@ -1463,6 +1489,34 @@ export type WeirdMessenger = Messenger<'Weird', WeirdAction, never>; }); }); + it('skips a capability-type-constructor alias whose first type argument is not a string literal', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // The recognized constructor name is used, but the first type argument + // resolves to `number` (not a string literal), so nothing is extracted. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +type ControllerGetStateAction = { + type: \`\${T & string}:getState\`; + handler: () => S; +}; + +export type WeirdAction = ControllerGetStateAction; +`, + { actions: ['WeirdAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toStrictEqual([]); + }); + }); + it('ignores capability-type-constructor aliases with insufficient type arguments', async () => { expect.assertions(1); @@ -1572,6 +1626,45 @@ export type FooAction = { }); }); + it('skips qualified-name type references (namespace imports used as Ns.Type)', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + await fs.promises.writeFile( + path.join(directoryPath, 'ns.ts'), + ` +export type NsAction = { + type: 'Ns:do'; + handler: () => void; +}; +`, + ); + + const filePath = path.join(directoryPath, 'types.ts'); + // A namespace import produces a qualified-name type reference + // (`* as Ns` then `Ns.NsAction`). The walker cannot resolve qualified + // names and should skip the slot without crashing, leaving no items. + await fs.promises.writeFile( + filePath, + ` +import * as Ns from './ns'; + +export type LocalAction = { + type: 'Local:do'; + handler: () => void; +}; + +export type LocalMessenger = Messenger<'Local', LocalAction | Ns.NsAction, never>; +`, + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toHaveLength(1); + expect(items[0].typeName).toBe('LocalAction'); + }); + }); + it('skips non-relative imports when resolving constants', async () => { expect.assertions(1); @@ -1598,6 +1691,39 @@ export type FooAction = { }); }); + it('skips a @param tag that has no name', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // A bare `@param - description` with no parameter name is malformed JSDoc. + // ts-morph parses it as a JSDocParameterTag whose nameNode.getText() is + // an empty string. The extractor should skip it rather than emit a param + // entry with an empty name. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +/** + * Does something. + * @param - no name here + */ +export type FooAction = { + type: 'Foo:do'; + handler: () => void; +}; +`, + { actions: ['FooAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toHaveLength(1); + expect(items[0].params).toStrictEqual([]); + }); + }); + it('captures multi-line @param tags as structured params and keeps the description', async () => { expect.assertions(4); @@ -1717,4 +1843,254 @@ export type FooAction = { expect(items[0].jsDoc).toContain('Text after empty asterisk.'); }); }); + + it('handles a capability type body that contains a method signature alongside property signatures', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // A type literal can contain method signatures in addition to property + // signatures. The method signature should be skipped while walking the + // members, and the `type` property signature should still be found. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +export type FooAction = { + doSomething(): void; + type: 'Foo:do'; + handler: () => void; +}; +`, + { actions: ['FooAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toHaveLength(1); + expect(items[0].typeString).toBe('Foo:do'); + }); + }); + + it('resolves the type string when the `type` property uses a no-substitution template literal', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // A template literal with no substitutions (e.g. `Foo:bar`) is valid + // TypeScript. The extractor should treat it the same as a quoted string. + await fs.promises.writeFile( + filePath, + withMessenger( + [ + 'export type FooAction = {', + ' type: `Foo:bar`;', + ' handler: () => void;', + '};', + ].join('\n'), + { actions: ['FooAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toHaveLength(1); + expect(items[0].typeString).toBe('Foo:bar'); + }); + }); + + it('skips a capability type whose `type` property uses a numeric literal type', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // `type: 42` is valid TypeScript but not a valid messenger type string. + // The extractor should return null for this shape and produce no output. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +export type FooAction = { + type: 42; + handler: () => void; +}; +`, + { actions: ['FooAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toStrictEqual([]); + }); + }); + + it('skips a capability type whose body has no `type` property', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // A type alias that is referenced by a Messenger but has no `type` + // property cannot produce a type string, so it should be skipped. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +export type FooAction = { + handler: () => void; +}; +`, + { actions: ['FooAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toStrictEqual([]); + }); + }); + + it('renders **Deprecated:** without trailing text when @deprecated tag has no comment', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // A bare `@deprecated` tag with no explanatory text is valid JSDoc. + // The extractor should still emit the **Deprecated:** marker. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +/** + * Old action. + * @deprecated + */ +export type FooAction = { + type: 'Foo:old'; + handler: () => void; +}; +`, + { actions: ['FooAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items[0].deprecated).toBe(true); + expect(items[0].jsDoc).toContain('**Deprecated:**'); + }); + }); + + it('falls back to `unknown` for a handler parameter that has no explicit type annotation', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // TypeScript allows untyped parameters when `noImplicitAny` is off. + // The extractor should fall back to `unknown` rather than crashing. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +class FooController { + doStuff(x): boolean { + return Boolean(x); + } +} + +export type FooControllerDoStuffAction = { + type: 'FooController:doStuff'; + handler: FooController['doStuff']; +}; +`, + { actions: ['FooControllerDoStuffAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items[0].handlerOrPayload).toContain('x: unknown'); + }); + }); + + it('falls back to `void` for a handler method that has no explicit return type annotation', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // A method with an implicit return type (e.g. inferred from the body) + // has no `ReturnTypeNode`. The extractor should fall back to `void`. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +class FooController { + doStuff() {} +} + +export type FooControllerDoStuffAction = { + type: 'FooController:doStuff'; + handler: FooController['doStuff']; +}; +`, + { actions: ['FooControllerDoStuffAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items[0].handlerOrPayload).toContain('=> void'); + }); + }); + + it('skips an inline action type that has a valid `type` string but no `handler` property', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // An action type that is missing the `handler` property cannot be + // extracted. The extractor should skip it rather than crash. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +export type FooAction = { + type: 'Foo:do'; +}; +`, + { actions: ['FooAction'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toStrictEqual([]); + }); + }); + + it('skips an inline event type that has a valid `type` string but no `payload` property', async () => { + expect.assertions(1); + + await withinSandbox(async ({ directoryPath }) => { + const filePath = path.join(directoryPath, 'types.ts'); + // An event type that is missing the `payload` property cannot be + // extracted. The extractor should skip it rather than crash. + await fs.promises.writeFile( + filePath, + withMessenger( + ` +export type FooEvent = { + type: 'Foo:change'; +}; +`, + { events: ['FooEvent'] }, + ), + ); + + const items = await extractFromFile(filePath, directoryPath); + + expect(items).toStrictEqual([]); + }); + }); }); diff --git a/packages/platform-api-docs/src/extraction.ts b/packages/platform-api-docs/src/extraction.ts index bc2c4f4501..76186f978b 100644 --- a/packages/platform-api-docs/src/extraction.ts +++ b/packages/platform-api-docs/src/extraction.ts @@ -7,7 +7,6 @@ import type { Node as TsMorphNode, PropertySignature, SourceFile, - TemplateLiteralTypeNode, TypeAliasDeclaration, TypeElementTypes, TypeNode, @@ -15,19 +14,26 @@ import type { import { Node as NodeGuards, Project, ts } from 'ts-morph'; import type { - ExtractedMessengerCapabilityType, + MessengerCapabilityPacket, MethodInfo, DocumentedParameter, } from './types'; +// --------------------------------------------------------------------------- +// NOTE: `ts-morph` is used heavily in this file to parse and extract +// information from TypeScript files. Although this library is not well +// documented, it wraps the TypeScript AST fairly well, and you can get a good +// sense of the AST by using this website: +// --------------------------------------------------------------------------- + // --------------------------------------------------------------------------- // JSDoc utilities // --------------------------------------------------------------------------- /** - * Convert `{@link X}` references inside a string to plain backtick code spans, - * and escape any remaining (out-of-backtick) curly braces so the output is - * safe to drop into MDX. + * Convert `{@link X}` references inside a JSDoc comment string to plain + * backtick code spans and escape any remaining (out-of-backtick) curly braces. + * This way the output is safe to render in a MDX document. * * @param text - The raw text to normalize. * @returns The text with `@link` resolved and stray braces escaped. @@ -49,34 +55,32 @@ function escapeJsDocTextForMdx(text: string): string { } /** - * Extract a JSDoc tag's comment text, normalizing whitespace so continuation - * lines are joined with single spaces. ts-morph returns the raw comment with - * embedded newlines preserved; we collapse those for the markdown output. + * Extract the comment text of a JSDoc tag — the part that comes after the tag + * and any identifier, e.g. "Some param" in "@param foo Some param" — + * normalizing whitespace to a single space so we can better control how it's + * rendered within the site. * * @param tag - The JSDoc tag. * @returns The flattened comment text. */ function extractJsDocTagComment(tag: JSDocTag): string { - // istanbul ignore next: ts-morph returns null for tags without comment text, - // but every fixture tag we extract from carries a comment. return (tag.getCommentText() ?? '').replace(/\s+/gu, ' ').trim(); } /** * Strip the conventional `- ` separator from the start of a `@param` tag's - * comment. JSDoc style is `@param name - description`, and ts-morph hands us - * back `- description` for the comment. The hyphen is purely cosmetic; we'd - * rather render the description without it. + * comment. * * @param comment - The flattened comment text from a `@param` tag. * @returns The comment with any leading `- ` (or `– `, `— `) removed. */ -function stripParamSeparator(comment: string): string { +function stripJsDocParamSeparator(comment: string): string { return comment.replace(/^[-–—]\s*/u, ''); } /** - * Decompose a node's JSDoc into the parts the rendered docs need: + * Extract JSDoc from a TypeScript AST node and decompose it into the parts we + * need to render docs: * * - `description` — the body above the first tag, with `@deprecated` comments * appended as `**Deprecated:** ` lines and normalized for MDX @@ -86,7 +90,7 @@ function stripParamSeparator(comment: string): string { * * Other tags (`@see`, `@throws`, `@template`, `@example`) are dropped. * - * @param node - The AST node to extract JSDoc from. + * @param node - The AST node to extract JSDoc from (e.g. a type or a method). * @returns The decomposed JSDoc; empty strings/arrays when the node has no JSDoc. */ function extractJsDoc(node: JSDocableNode): { @@ -110,25 +114,23 @@ function extractJsDoc(node: JSDocableNode): { const tagName = tag.getTagName(); if (tagName === 'deprecated') { const comment = extractJsDocTagComment(tag); - // istanbul ignore next: bare `@deprecated` (without explanatory text) - // doesn't appear in messenger JSDoc in practice. deprecatedLines.push( comment ? `**Deprecated:** ${comment}` : '**Deprecated:**', ); } else if (tagName === 'param' && NodeGuards.isJSDocParameterTag(tag)) { const nameNode = tag.getNameNode(); - // istanbul ignore next: `@param` tags without a name aren't valid JSDoc. - if (!nameNode) { + const paramName = nameNode.getText(); + if (!paramName) { continue; } + const comment = extractJsDocTagComment(tag); params.push({ - name: nameNode.getText(), - description: escapeJsDocTextForMdx( - stripParamSeparator(extractJsDocTagComment(tag)), - ), + name: paramName, + description: escapeJsDocTextForMdx(stripJsDocParamSeparator(comment)), }); } else if (tagName === 'returns' || tagName === 'return') { - returns = escapeJsDocTextForMdx(extractJsDocTagComment(tag)); + const comment = extractJsDocTagComment(tag); + returns = escapeJsDocTextForMdx(comment); } } @@ -147,7 +149,7 @@ function extractJsDoc(node: JSDocableNode): { * @param node - The AST node to check. * @returns True if the node has an `@deprecated` tag. */ -function isDeprecated(node: JSDocableNode): boolean { +function hasDeprecatedJsDocTag(node: JSDocableNode): boolean { return node .getJsDocs() .flatMap((jsDoc) => jsDoc.getTags()) @@ -158,54 +160,6 @@ function isDeprecated(node: JSDocableNode): boolean { // Type-resolution helpers (powered by ts-morph's type checker) // --------------------------------------------------------------------------- -/** - * Resolve a TemplateLiteralTypeNode (e.g. `` `${typeof X}:foo` ``) to its - * concrete string value, using the type checker to follow `typeof X` to its - * literal type. Returns null if the type checker can't reduce it to a single - * string literal. - * - * @param node - The template literal type node. - * @returns The resolved string, or null. - */ -function resolveTemplateLiteralType( - node: TemplateLiteralTypeNode, -): string | null { - const type = node.getType(); - if (type.isStringLiteral()) { - return type.getLiteralValueOrThrow() as string; - } - // istanbul ignore next: messenger fixtures always reduce template literal - // types to a single string literal via `typeof X` constants. - return null; -} - -/** - * Resolve a capability-type-constructor's first generic argument to a - * namespace string. Accepts either `typeof X` (resolved via the type checker) - * or a string literal. - * - * @param typeArg - The first generic argument node. - * @returns The resolved namespace, or null. - */ -function resolveNamespaceFromTypeArg(typeArg: TsMorphNode): string | null { - if (NodeGuards.isTypeQuery(typeArg)) { - const type = typeArg.getType(); - if (type.isStringLiteral()) { - return type.getLiteralValueOrThrow() as string; - } - } - if (NodeGuards.isLiteralTypeNode(typeArg)) { - const literal = typeArg.getLiteral(); - // istanbul ignore else: only string literals are valid namespace args. - if (NodeGuards.isStringLiteral(literal)) { - return literal.getLiteralValue(); - } - } - // istanbul ignore next: namespace args are always a `typeof X` query or a - // string literal in valid messenger usage. - return null; -} - /** * If `typeNode` is `Class['method']`, resolve `Class` to its declaration * (across files if needed via the type checker) and look up the method by @@ -297,16 +251,12 @@ function buildMethodInfo(method: MethodDeclaration): MethodInfo { const paramName = param.getNameNode().getText(); const optional = param.hasQuestionToken() ? '?' : ''; const typeNode = param.getTypeNode(); - // istanbul ignore next: handler parameters in messenger fixtures always - // declare an explicit type. const paramType = typeNode ? typeNode.getText() : 'unknown'; return `${paramName}${optional}: ${paramType}`; }) .join(', '); const returnTypeNode = method.getReturnTypeNode(); - // istanbul ignore next: handler class methods in messenger fixtures always - // declare an explicit return type. const returnType = returnTypeNode ? returnTypeNode.getText() : 'void'; // For async methods, the declared return type already includes `Promise<>`, // so we don't need to wrap again. @@ -322,147 +272,228 @@ function buildMethodInfo(method: MethodDeclaration): MethodInfo { // --------------------------------------------------------------------------- /** - * A capability type declared somewhere reachable from a `*Messenger`, along - * with the kind (action/event) it was found under. + * Represents a type declaration (type alias or interface) for an individual + * messenger action or event. */ type MessengerCapabilityTypeDeclaration = { - statement: TypeAliasDeclaration | InterfaceDeclaration; + declaration: TypeAliasDeclaration | InterfaceDeclaration; kind: 'action' | 'event'; }; /** - * Find every `*Messenger` type alias in a source file, parse its - * `Messenger` generic arguments, and return the - * capability-type declarations referenced from the Actions and Events slots - * — paired with the kind under which they were referenced. - * - * The walker follows imported references via ts-morph's symbol resolution, - * so capability types declared in sibling files (e.g. the auto-generated - * `*-method-action-types.ts` files) are discovered even though only the - * `*Messenger` declaration is local to this file. + * Represents a type alias for a messenger. Only includes nodes representing the + * `Actions` and `Events` type parameters. + */ +type ParsedMessengerTypeAlias = { + actionsTypeParameter: TypeNode; + eventsTypeParameter: TypeNode; +}; + +/** + * Looks for Messenger types in the source file (that is, those that are type + * aliases whose names end with "Messenger"), then extracts the `Actions` and + * `Events` parameters from these types. * * @param sourceFile - The TypeScript source file to scan. - * @returns The list of locally- and transitively-referenced capability types. + * @returns A list of objects that represent messenger types. */ -function collectMessengerCapabilityTypeDeclarations( +function findMessengerTypeAliases( sourceFile: SourceFile, -): MessengerCapabilityTypeDeclaration[] { - const declarations: MessengerCapabilityTypeDeclaration[] = []; - const seen = new Set(); +): ParsedMessengerTypeAlias[] { + const parsedMessengerTypeAliases: ParsedMessengerTypeAlias[] = []; for (const typeAlias of sourceFile.getTypeAliases()) { if (!typeAlias.getName().endsWith('Messenger')) { continue; } + const body = typeAlias.getTypeNode(); + // Basic check if (!body || !NodeGuards.isTypeReference(body)) { continue; } + const typeArgs = body.getTypeArguments(); + // Messenger types always have 3 type parameters + // (e.g. `Messenger<'FooController', Actions, Events>`) if (typeArgs.length < 3) { continue; } - walkCapabilityTypes(typeArgs[1], 'action', declarations, seen); - walkCapabilityTypes(typeArgs[2], 'event', declarations, seen); + parsedMessengerTypeAliases.push({ + actionsTypeParameter: typeArgs[1], + eventsTypeParameter: typeArgs[2], + }); } - return declarations; + return parsedMessengerTypeAliases; } /** - * Walk an Actions or Events type-argument tree and append the underlying - * capability-type declarations to `output`. Unions are expanded. Type - * references are resolved via ts-morph's symbol resolution (so imported - * names are followed across files); a resolved alias whose body is itself a - * union expands transparently, leaving the intermediate alias unrecorded. - * - * A resolved alias whose body is a single non-union type reference (e.g. - * `type AllowedActions = ConnectivityControllerGetStateAction`) is treated - * as an opaque re-export — the walk stops there, leaving the target to be - * documented from its home package by the dedup logic later. + * Walks the `Actions` and `Events` type parameters of the given messenger + * types, extracted in a previous step, to find all type declarations (i.e., + * statements) that represent individual messenger actions or events. * - * For example, given: - * - * ```typescript - * // NetworkController.ts - * import type { NetworkControllerMethodActions } from './NetworkController-method-action-types'; - * type NetworkControllerActions = - * | NetworkControllerGetStateAction - * | NetworkControllerMethodActions; - * type NetworkControllerMessenger = Messenger; - * - * // NetworkController-method-action-types.ts - * export type NetworkControllerAddNetworkAction = { - * type: 'NetworkController:addNetwork'; - * handler: NetworkController['addNetwork']; - * }; - * // ... more ... - * export type NetworkControllerMethodActions = - * | NetworkControllerAddNetworkAction - * | ...; - * ``` - * - * walking the Actions slot yields each individual `NetworkController*Action` - * declaration — both the local `NetworkControllerGetStateAction` and the - * cross-file ones reached via `NetworkControllerMethodActions`. + * @param parsedMessengerTypeAliases - The list of objects representing + * messenger types, parsed in a previous step. + * @returns The list of type aliases that represent messenger capabilities among + * the given messenger types. + */ +function findAllMessengerCapabilityTypeDeclarations( + parsedMessengerTypeAliases: ParsedMessengerTypeAlias[], +): MessengerCapabilityTypeDeclaration[] { + const allCapabilityTypeDeclarations: MessengerCapabilityTypeDeclaration[] = + []; + let allVisitedTypeDeclarations: Set = new Set(); + + for (const { + actionsTypeParameter, + eventsTypeParameter, + } of parsedMessengerTypeAliases) { + for (const [typeParameter, kind] of [ + [actionsTypeParameter, 'action'], + [eventsTypeParameter, 'event'], + ] as const) { + const result = recursivelyFindMessengerCapabilityTypeDeclarations( + typeParameter, + kind, + allVisitedTypeDeclarations, + ); + allCapabilityTypeDeclarations.push(...result.capabilityTypeDeclarations); + allVisitedTypeDeclarations = result.visitedTypeDeclarations; + } + } + + return allCapabilityTypeDeclarations; +} + +/** + * Recursively walks a `ts-morph` AST node — at first the `Actions` or `Events` + * type parameter of a messenger type, and then a node within that parameter — + * to find all type aliases that represent individual messenger actions or + * events, no matter how deeply the type aliases exist in the tree or in which + * file they are located. * - * @param node - The Actions or Events type-argument node to walk. - * @param kind - Whether to tag found declarations as 'action' or 'event'. - * @param output - The list to append discovered declarations to. - * @param seen - Declaration nodes already visited (prevents cycles and - * duplicate work). + * @param node - The `ts-morph` AST node to walk. + * @param kind - Whether to tag found type aliases as 'action' or 'event'. + * @param visitedTypeDeclarations - A variable that tracks visited type aliases + * and prevents duplicates. + * @returns The list of extracted messenger capability type aliases as well as + * an updated version of `visitedTypeDeclarations`. */ -function walkCapabilityTypes( +function recursivelyFindMessengerCapabilityTypeDeclarations( node: TsMorphNode, kind: 'action' | 'event', - output: MessengerCapabilityTypeDeclaration[], - seen: Set, -): void { + visitedTypeDeclarations: Set, +): { + capabilityTypeDeclarations: MessengerCapabilityTypeDeclaration[]; + visitedTypeDeclarations: Set; +} { + const result: { + capabilityTypeDeclarations: MessengerCapabilityTypeDeclaration[]; + visitedTypeDeclarations: Set; + } = { + capabilityTypeDeclarations: [], + visitedTypeDeclarations: new Set([...visitedTypeDeclarations]), + }; + + // If `node` is a union type, walk each type within it. + // EXAMPLES: + // type Actions = FooControllerSomeAction | FooControllerSomeOtherAction + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // type FooControllerActions = FooControllerSomeAction | FooControllerSomeOtherAction + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ if (NodeGuards.isUnionTypeNode(node)) { - for (const member of node.getTypeNodes()) { - walkCapabilityTypes(member, kind, output, seen); + for (const typeNode of node.getTypeNodes()) { + const innerResult = recursivelyFindMessengerCapabilityTypeDeclarations( + typeNode, + kind, + result.visitedTypeDeclarations, + ); + result.capabilityTypeDeclarations.push( + ...innerResult.capabilityTypeDeclarations, + ); + for (const typeDeclaration of innerResult.visitedTypeDeclarations) { + result.visitedTypeDeclarations.add(typeDeclaration); + } } - return; - } - + return result; + } + + // If `node` is not a type reference, don't walk it. + // EXAMPLE: + // // Bad + // type Actions = { ... } + // ^^^^^^^ + // // Good + // type Actions = FooControllerSomeAction; + // // Good + // type Actions = ControllerGetStateAction<...>; if (!NodeGuards.isTypeReference(node)) { - return; + return result; } + const nameNode = node.getTypeName(); - // istanbul ignore next: qualified-name references aren't used in messenger - // generic arguments. + + // Reject qualified-name type references, as we can't follow those. + // EXAMPLE: + // import * as somePackage from '...'; + // type Actions = somePackage.FooControllerSomeAction; + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ if (!NodeGuards.isIdentifier(nameNode)) { - return; - } - // For a TypeReference whose name was imported, `getSymbol()` returns the - // import alias symbol — its only declaration is the `ImportSpecifier`. - // `getAliasedSymbol()` follows the alias to the original declaration in - // the imported file. Use it when present; otherwise fall back to the - // (already-local) symbol. - const localSymbol = nameNode.getSymbol(); - // istanbul ignore next: a referenced type name always resolves to a symbol - // in a typechecked project. - if (!localSymbol) { - return; - } + return result; + } + + // Since we know we have a type reference, we can assume that we have a symbol. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const localSymbol = nameNode.getSymbol()!; + // If we have a type 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 { FooControllerSomeAction } from '@metamask/foo-controller'; + // type Actions = FooControllerSomeAction; + // ^^^^^^^^^^^^^^^^^^^^^^^ const symbol = localSymbol.getAliasedSymbol() ?? localSymbol; - // istanbul ignore next: a resolved symbol always exposes its declarations - // array in a typechecked project. - for (const declaration of symbol.getDeclarations() ?? []) { - if (seen.has(declaration)) { + // At this point, we have a type *reference*, but we need to find the type + // *declaration*. + // For instance, if we have `FooControllerSomeAction`, we need to find + // the full `type FooControllerSomeAction = ...` statement. + for (const declaration of symbol.getDeclarations()) { + // Prevent duplicates + if (result.visitedTypeDeclarations.has(declaration)) { continue; } - seen.add(declaration); + result.visitedTypeDeclarations.add(declaration); + // If we have a type alias, then we have to handle a few scenarios. + // EXAMPLES: + // type FooControllerMethodActions = ... + // type FooControllerSomeAction = ... if (NodeGuards.isTypeAliasDeclaration(declaration)) { - const aliasBody = declaration.getTypeNode(); - if (aliasBody && NodeGuards.isUnionTypeNode(aliasBody)) { - // Umbrella union — descend through it without documenting the alias. - walkCapabilityTypes(aliasBody, kind, output, seen); + const body = declaration.getTypeNode(); + + // If we have a union type, then walk each type in the union. + // EXAMPLE: + // type FooControllerMethodActions = FooControllerSomeAction | FooControllerSomeOtherAction + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + if (body && NodeGuards.isUnionTypeNode(body)) { + const innerResult = recursivelyFindMessengerCapabilityTypeDeclarations( + body, + kind, + result.visitedTypeDeclarations, + ); + result.capabilityTypeDeclarations.push( + ...innerResult.capabilityTypeDeclarations, + ); + for (const typeDeclaration of innerResult.visitedTypeDeclarations) { + result.visitedTypeDeclarations.add(typeDeclaration); + } continue; } + + // TODO: Is this necessary? // A bare TypeReference body with no type arguments (e.g. // `type AllowedActions = ConnectivityControllerGetStateAction`) is a // plain re-export — leave the target to be documented from its home @@ -470,18 +501,32 @@ function walkCapabilityTypes( // capability-type-constructor invocations (e.g. // `ControllerGetStateAction`) and are recorded. if ( - aliasBody && - NodeGuards.isTypeReference(aliasBody) && - aliasBody.getTypeArguments().length === 0 + body && + NodeGuards.isTypeReference(body) && + body.getTypeArguments().length === 0 ) { continue; } - // TypeLiteral body, capability-type-constructor invocation, etc. - output.push({ statement: declaration, kind }); - } else if (NodeGuards.isInterfaceDeclaration(declaration)) { - output.push({ statement: declaration, kind }); + + // We finally found a messenger capability type! Capture the whole thing. + // (We will parse the body later.) + // EXAMPLE: + // type FooControllerSomeAction = { ... } + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + result.capabilityTypeDeclarations.push({ declaration, kind }); + } + + // If we have an interface, we don't have any scenarios to handle, so + // capture it as is. + // EXAMPLE: + // interface FooControllerSomeAction { ... } + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + else if (NodeGuards.isInterfaceDeclaration(declaration)) { + result.capabilityTypeDeclarations.push({ declaration, kind }); } } + + return result; } // --------------------------------------------------------------------------- @@ -489,101 +534,123 @@ function walkCapabilityTypes( // --------------------------------------------------------------------------- /** - * Extract a single capability-type declaration to an - * {@link ExtractedMessengerCapabilityType}. + * Given the declaration of a messenger capability type, extract information + * about it (action/event type string, handler/payload arguments and return + * type, etc.) * - * @param statement - The type alias or interface declaration. - * @param kind - Whether this statement is referenced as an action or an event. + * @param capabilityTypeDeclaration - The statement that declared the type for a + * messenger action or event, extracted in a previous step. * @param projectPath - Project root, used for computing relative source paths. - * @returns The extracted capability, or null if no recognized pattern matches. + * @returns Information that may be extracted from the messenger capability type + * (may be `null` if the type is ineligible for extraction). */ -function extractFromMessengerCapabilityType( - statement: TypeAliasDeclaration | InterfaceDeclaration, - kind: 'action' | 'event', +function extractFromMessengerCapabilityTypeDeclaration( + capabilityTypeDeclaration: MessengerCapabilityTypeDeclaration, projectPath: string, -): ExtractedMessengerCapabilityType | null { - const inlineCapability = extractFromInlineMessengerCapabilityType( - statement, - kind, - projectPath, +): MessengerCapabilityPacket | null { + return ( + tryToExtractFromMessengerCapabilityTypeLiteral( + capabilityTypeDeclaration, + projectPath, + ) ?? + tryToExtractFromCapabilityTypeConstructor( + capabilityTypeDeclaration, + projectPath, + ) ); - if (inlineCapability) { - return inlineCapability; - } - - if (NodeGuards.isTypeAliasDeclaration(statement)) { - return extractFromCapabilityTypeConstructor(statement, kind, projectPath); - } - - // istanbul ignore next: interface declarations always have members and - // therefore an inline shape, so the inline branch above always returns - // non-null here. - return null; } /** - * Try the inline messenger-capability-type pattern: - * `{ type: '...'; handler: ... }` (action) or - * `{ type: '...'; payload: ... }` (event), expressed as either a type alias - * body or an interface body. + * If a messenger capability type is a type alias or interface and its body is + * a literal object type — i.e. one of: * - * @param statement - The type alias or interface declaration. - * @param kind - The expected kind (action or event). + * - `{ type: '...'; handler: ... }` (action) + * - `{ type: '...'; payload: ... }` (event) + * + * then this function extracts information about the type (action/event type + * string, handler/payload arguments and return type, etc.) + * + * @param capabilityTypeDeclaration - The statement that declared the type for a + * messenger action or event, extracted in a previous step. * @param projectPath - Project root, used for computing relative source paths. - * @returns The extracted capability, or null if the shape doesn't match. + * @returns The extracted capability packet, or null if the shape of the type + * doesn't match. */ -function extractFromInlineMessengerCapabilityType( - statement: TypeAliasDeclaration | InterfaceDeclaration, - kind: 'action' | 'event', +function tryToExtractFromMessengerCapabilityTypeLiteral( + capabilityTypeDeclaration: MessengerCapabilityTypeDeclaration, projectPath: string, -): ExtractedMessengerCapabilityType | null { +): MessengerCapabilityPacket | null { + const { declaration, kind } = capabilityTypeDeclaration; + + // Reject empty type aliases or interfaces. + // EXAMPLES: + // // Good + // type FooControllerSomeAction = { + // type: 'FooController:getState'; + // handler: FooController['getState']; + // } + // // Good + // interface FooControllerSomeAction { + // type: 'FooController:getState'; + // handler: FooController['getState']; + // } + // // Bad + // type FooControllerSomeAction = {}; + // // Bad + // interface FooControllerSomeAction {}; let members: TypeElementTypes[] | undefined; - if (NodeGuards.isTypeAliasDeclaration(statement)) { - const body = statement.getTypeNode(); + if (NodeGuards.isTypeAliasDeclaration(declaration)) { + const body = declaration.getTypeNode(); if (body && NodeGuards.isTypeLiteral(body)) { members = body.getMembers(); } } else { - members = statement.getMembers(); + members = declaration.getMembers(); } if (!members) { return null; } - const typeString = resolveInlineTypeString(members); - // istanbul ignore next: capabilities reachable via the messenger walk - // always have a resolvable `Namespace:name` type string. - if (!typeString?.includes(':')) { - return null; - } + const propertySignatures = members.filter( + NodeGuards.isPropertySignature.bind(NodeGuards), + ); - const handlerMember = getPropertyMember(members, 'handler'); - const payloadMember = getPropertyMember(members, 'payload'); - const rawSourceMember = kind === 'action' ? handlerMember : payloadMember; - // istanbul ignore next: actions always have `handler` and events always - // have `payload`; the messenger walk wouldn't have surfaced this - // declaration otherwise. - if (!rawSourceMember) { + // Actions and events must have a `type`, and it must be a string. + const typeString = getMessengerCapabilityTypeString(propertySignatures); + if (!typeString) { return null; } - const rawSourceTypeNode = rawSourceMember.getTypeNode(); - // istanbul ignore next: property signatures we care about always have an - // explicit type. - if (!rawSourceTypeNode) { + + // Actions must have a `handler`, and events must have a `payload`. + const handlerOrPayloadProperty = findProperty( + propertySignatures, + kind === 'action' ? 'handler' : 'payload', + ); + if (!handlerOrPayloadProperty) { return null; } - let handlerOrPayload = rawSourceTypeNode.getText().trim(); - let { description: jsDoc, params, returns } = extractJsDoc(statement); + const handlerOrPayloadPropertyTypeNode = + // We can assume the property has a type; otherwise it wouldn't compile. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + handlerOrPayloadProperty.getTypeNode()!; + let handlerOrPayloadSignature = handlerOrPayloadPropertyTypeNode + .getText() + .trim(); - // For actions, if the handler resolves to a class method (`Class['method']` - // — possibly in another file), inherit its signature plus any JSDoc fields - // the type alias itself doesn't already provide. + let { description: jsDoc, params, returns } = extractJsDoc(declaration); + + // For actions, if the handler resolves to a class method (e.g. + // `FooController['method']`), inherit its signature plus any JSDoc fields the + // type alias itself doesn't already provide. + // TODO: We don't want to do this. if (kind === 'action') { - const resolvedMethod = resolveIndexedAccessMethod(rawSourceTypeNode); + const resolvedMethod = resolveIndexedAccessMethod( + handlerOrPayloadPropertyTypeNode, + ); if (resolvedMethod) { const info = buildMethodInfo(resolvedMethod); - handlerOrPayload = info.signature; + handlerOrPayloadSignature = info.signature; if (!jsDoc && info.jsDoc) { jsDoc = info.jsDoc; } @@ -596,167 +663,205 @@ function extractFromInlineMessengerCapabilityType( } } - const sourceFile = statement.getSourceFile(); + const sourceFile = declaration.getSourceFile(); return { - typeName: statement.getName(), + typeName: declaration.getName(), typeString, kind, jsDoc, params, returns, - handlerOrPayload, + handlerOrPayload: handlerOrPayloadSignature, sourceFile: path.relative(projectPath, sourceFile.getFilePath()), - line: statement.getStartLineNumber(), - deprecated: isDeprecated(statement), + line: declaration.getStartLineNumber(), + deprecated: hasDeprecatedJsDocTag(declaration), }; } /** - * Resolve the literal value of an inline shape's `type` property — either a - * direct string literal or a template literal type with `typeof X` - * substitutions (resolved via the type checker). + * Searches the property signatures of a messenger capability type alias or + * interface to find the value of the `type` property, and then resolves it to a + * string (assuming it is already a string or a resolvable template literal). * - * @param members - The type elements of the inline shape. - * @returns The resolved type string, or null if it can't be resolved. + * @param capabilityTypeProperties - The property signatures of the messenger + * capability type. + * @returns The extracted capability type string, or null if `type` cannot be + * found in the members or it is an unexpected node. */ -function resolveInlineTypeString(members: TypeElementTypes[]): string | null { - for (const member of members) { - // istanbul ignore next: capability-type bodies only contain property - // signatures. - if (!NodeGuards.isPropertySignature(member)) { - continue; - } - const memberNameNode = member.getNameNode(); - if ( - // istanbul ignore next: `type` is always a plain identifier. - !NodeGuards.isIdentifier(memberNameNode) || - memberNameNode.getText() !== 'type' - ) { - continue; - } - const typeNode = member.getTypeNode(); - // istanbul ignore next: a `type` property without an explicit type - // wouldn't compile. - if (!typeNode) { - continue; - } - if (NodeGuards.isLiteralTypeNode(typeNode)) { - const literal = typeNode.getLiteral(); - // istanbul ignore next: messenger `type` fields are written as - // quoted string literals in real fixtures; backtick template literals - // are valid TypeScript but don't appear in practice. - if ( - NodeGuards.isStringLiteral(literal) || - NodeGuards.isNoSubstitutionTemplateLiteral(literal) - ) { - return literal.getLiteralValue(); - } - // istanbul ignore next: numeric/boolean literal types aren't valid as a - // messenger `type` and don't appear in real fixtures. - return null; - } - if (NodeGuards.isTemplateLiteralTypeNode(typeNode)) { - return resolveTemplateLiteralType(typeNode); +function getMessengerCapabilityTypeString( + capabilityTypeProperties: PropertySignature[], +): string | null { + const typeProperty = findProperty(capabilityTypeProperties, 'type'); + if (!typeProperty) { + return null; + } + + // A `type` property without an explicit type wouldn't compile. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const typeNode = typeProperty.getTypeNode()!; + + // Ask the type checker to resolve the value of `type`. We're looking for + // `type` to be either a string literal or template literal. + // + // EXAMPLES: + // type FooControllerSomeAction = { + // type: 'FooController:someAction'; + // ^^^^^^^^^^^^^^^^^^^^^^^^^^ + // } + // type FooControllerSomeAction = { + // type: `FooController:someAction`; + // ^^^^^^^^^^^^^^^^^^^^^^^^^^ + // } + // type FooControllerSomeAction = { + // type: `${typeof CONTROLLER_NAME}:someAction`; + // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + // } + const resolvedType = typeNode.getType(); + if (resolvedType.isStringLiteral()) { + // Type assertion: There aren't any type guards we can use to narrow this + // type further. + const literalValue = resolvedType.getLiteralValueOrThrow() as string; + + // Messenger action/event types need to be namespaced. + if (literalValue.includes(':')) { + return literalValue; } } - // istanbul ignore next: the inline shape always has a `type` member that - // produces either a literal or template-literal type. + return null; } /** - * Find the `PropertySignature` named `propName` in a type literal body. + * Finds a specific property in a list of property signatures for an object + * type. * - * @param members - The type literal members to search. - * @param propName - The property name to find. + * @param propertySignatures - The property signatures of the messenger + * capability type. + * @param name - The property name to find. * @returns The property signature, or null. */ -function getPropertyMember( - members: TypeElementTypes[], - propName: string, +function findProperty( + propertySignatures: PropertySignature[], + name: string, ): PropertySignature | null { - for (const member of members) { - // istanbul ignore next: capability-type bodies only contain property - // signatures. - if (!NodeGuards.isPropertySignature(member)) { - continue; - } - const memberNameNode = member.getNameNode(); + for (const property of propertySignatures) { + const propertyNameNode = property.getNameNode(); if ( - // istanbul ignore next: capability properties always have identifier names. - !NodeGuards.isIdentifier(memberNameNode) || - memberNameNode.getText() !== propName + !NodeGuards.isIdentifier(propertyNameNode) || + propertyNameNode.getText() !== name ) { continue; } - return member; + + return property; } + return null; } /** - * Try the capability-type-constructor pattern: - * `ControllerGetStateAction` for actions, or - * `ControllerStateChangeEvent` for events. + * If a messenger capability type is a type alias for either the + * `ControllerGetStateAction` or `ControllerStateChangeEvent` type constructors, + * then this function extracts information about the type (action/event type + * string, handler/payload arguments and return type, etc.) * - * @param statement - The type alias declaration. - * @param kind - The expected kind (action or event). + * @param capabilityTypeDeclaration - The statement that declared the type for a + * messenger action or event, extracted in a previous step. * @param projectPath - Project root, used for computing relative source paths. - * @returns The extracted capability, or null if the constructor doesn't match. + * @returns The extracted capability packet, or null if the shape of the type + * doesn't match. */ -function extractFromCapabilityTypeConstructor( - statement: TypeAliasDeclaration, - kind: 'action' | 'event', +function tryToExtractFromCapabilityTypeConstructor( + capabilityTypeDeclaration: MessengerCapabilityTypeDeclaration, projectPath: string, -): ExtractedMessengerCapabilityType | null { - const aliasBody = statement.getTypeNode(); - // istanbul ignore next: walker only records aliases whose body matches. - if (!aliasBody || !NodeGuards.isTypeReference(aliasBody)) { +): MessengerCapabilityPacket | null { + const { declaration, kind } = capabilityTypeDeclaration; + + // Basic check: we need a type alias, otherwise the rest doesn't make sense. + // EXAMPLE: + // type FooControllerSomeAction = ... + if (!NodeGuards.isTypeAliasDeclaration(declaration)) { return null; } - const nameNode = aliasBody.getTypeName(); - // istanbul ignore next: qualified-name type references aren't used. - if (!NodeGuards.isIdentifier(nameNode)) { + + // We want our type alias to be a reference to a utility type. + // Non-null assertion: We can assume the type has a body of some kind, + // otherwise it wouldn't compile. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const body = declaration.getTypeNode()!; + // We already determined in + // `recursivelyFindMessengerCapabilityTypeDeclarations` that the declaration + // is a type reference. + // TODO: Capture the following information ahead of time so we don't need to + // check this again. + // istanbul ignore next + if (!NodeGuards.isTypeReference(body)) { return null; } - const constructorName = nameNode.getText(); - const typeArgs = aliasBody.getTypeArguments(); - if (typeArgs.length < 2) { + const typeName = body.getTypeName(); + // We already determined in + // `recursivelyFindMessengerCapabilityTypeDeclarations` that the type is an + // identifier. + // TODO: Capture the following information ahead of time so we don't need to + // check this again. + // istanbul ignore next + if (!NodeGuards.isIdentifier(typeName)) { return null; } + // The name of the utility type should be either `ControllerGetStateAction` + // (for actions) or `ControllerStateChangeEvent` (for events). + // EXAMPLES: + // type FooControllerSomeAction = ControllerGetStateAction<...> + // type FooControllerSomeEvent = ControllerStateChangeEvent<...> const expectedConstructor = kind === 'action' ? 'ControllerGetStateAction' : 'ControllerStateChangeEvent'; - if (constructorName !== expectedConstructor) { + if (typeName.getText() !== expectedConstructor) { + return null; + } + + // The utility type should take two parameters. + // EXAMPLES: + // type FooControllerSomeAction = ControllerGetStateAction<..., ...> + // type FooControllerSomeEvent = ControllerStateChangeEvent<..., ...> + const typeArgs = body.getTypeArguments(); + if (typeArgs.length < 2) { return null; } - const namespace = resolveNamespaceFromTypeArg(typeArgs[0]); - // istanbul ignore next: recognized constructors always have resolvable args. - if (!namespace) { + const namespaceArgType = typeArgs[0].getType(); + // The first parameter should be a string literal. + // EXAMPLES: + // type FooControllerSomeAction = ControllerGetStateAction<'FooController', ...> + // type FooControllerSomeAction = ControllerGetStateAction + if (!namespaceArgType.isStringLiteral()) { return null; } + // Type assertion: There aren't any type guards we can use to narrow this type + // further. + const namespace = namespaceArgType.getLiteralValueOrThrow() as string; + + const typeString = + kind === 'action' ? `${namespace}:getState` : `${namespace}:stateChange`; const stateArgText = typeArgs[1].getText(); - const { description, params, returns } = extractJsDoc(statement); + const handlerOrPayload = + kind === 'action' ? `() => ${stateArgText}` : `[${stateArgText}, Patch[]]`; + const { description, params, returns } = extractJsDoc(declaration); + const sourceFile = declaration.getSourceFile(); - const sourceFile = statement.getSourceFile(); return { - typeName: statement.getName(), - typeString: - kind === 'action' ? `${namespace}:getState` : `${namespace}:stateChange`, + typeName: declaration.getName(), + typeString, kind, jsDoc: description, params, returns, - handlerOrPayload: - kind === 'action' - ? `() => ${stateArgText}` - : `[${stateArgText}, Patch[]]`, + handlerOrPayload, sourceFile: path.relative(projectPath, sourceFile.getFilePath()), - line: statement.getStartLineNumber(), - deprecated: isDeprecated(statement), + line: declaration.getStartLineNumber(), + deprecated: hasDeprecatedJsDocTag(declaration), }; } @@ -791,38 +896,39 @@ export function createExtractionProject(): Project { } /** - * Extract every messenger action/event type reachable from a single source - * file's `*Messenger` declarations. + * Extract information (action/event type string, handler/payload arguments and + * return type, etc.) about every messenger action or event type which is + * reachable through all of a source file's `*Messenger` type declarations. * * The caller is responsible for ensuring `sourceFile` (plus any files it - * imports from) belongs to a ts-morph Project so cross-file symbol resolution + * imports from) belongs to a `ts-morph` Project so cross-file symbol resolution * works. * * @param sourceFile - The TypeScript source file to extract from. * @param projectPath - Project root, used for computing relative source paths. - * @returns The extracted capability list. + * @returns The extracted information about actions and events. */ export function extractFromSourceFile( sourceFile: SourceFile, projectPath: string, -): ExtractedMessengerCapabilityType[] { - const declarations = collectMessengerCapabilityTypeDeclarations(sourceFile); - if (declarations.length === 0) { - return []; - } - - const items: ExtractedMessengerCapabilityType[] = []; - for (const { statement, kind } of declarations) { - const item = extractFromMessengerCapabilityType( - statement, - kind, - projectPath, - ); - if (item) { - items.push(item); +): MessengerCapabilityPacket[] { + const messengerTypeAliases = findMessengerTypeAliases(sourceFile); + + const capabilityTypeDeclarations = + findAllMessengerCapabilityTypeDeclarations(messengerTypeAliases); + + const messengerCapabilityPackets: MessengerCapabilityPacket[] = []; + for (const capabilityTypeDeclaration of capabilityTypeDeclarations) { + const messengerCapabilityPacket = + extractFromMessengerCapabilityTypeDeclaration( + capabilityTypeDeclaration, + projectPath, + ); + if (messengerCapabilityPacket) { + messengerCapabilityPackets.push(messengerCapabilityPacket); } } - return items; + return messengerCapabilityPackets; } /** @@ -841,7 +947,7 @@ export function extractFromSourceFile( export async function extractFromFile( filePath: string, projectPath: string, -): Promise { +): Promise { const project = createExtractionProject(); const parentDir = path.dirname(filePath); // Load the file's directory so single-hop relative imports resolve. diff --git a/packages/platform-api-docs/src/generate.ts b/packages/platform-api-docs/src/generate.ts index 353d10ba5b..70defa885b 100644 --- a/packages/platform-api-docs/src/generate.ts +++ b/packages/platform-api-docs/src/generate.ts @@ -12,7 +12,7 @@ import { generateNamespacePage, generateSidebars, } from './markdown'; -import type { ExtractedMessengerCapabilityType, NamespaceGroup } from './types'; +import type { MessengerCapabilityPacket, NamespaceGroup } from './types'; /** * Compute a deduplication score for a messenger item, preferring items with @@ -21,7 +21,7 @@ import type { ExtractedMessengerCapabilityType, NamespaceGroup } from './types'; * @param item - The messenger item to score. * @returns A numeric score (higher is better). */ -function deduplicationScore(item: ExtractedMessengerCapabilityType): number { +function deduplicationScore(item: MessengerCapabilityPacket): number { const jsDocScore = item.jsDoc ? 2 : 0; const namespacePrefix = item.typeString .split(':')[0] @@ -211,8 +211,8 @@ async function extractFromDirectory( directory: string, projectPath: string, findFiles: (dir: string) => Promise, -): Promise { - const items: ExtractedMessengerCapabilityType[] = []; +): Promise { + const items: MessengerCapabilityPacket[] = []; const files = await findFiles(directory); for (const file of files) { try { @@ -276,9 +276,9 @@ async function listTargetSubdirectories( async function scanSources( projectPath: string, sources: ScanSources, -): Promise { +): Promise { const project = createExtractionProject(); - const allItems: ExtractedMessengerCapabilityType[] = []; + const allItems: MessengerCapabilityPacket[] = []; for (const dir of sources.scanDirs) { allItems.push( @@ -341,8 +341,8 @@ async function scanSources( */ function replaceDuplicateInGroup( byNamespace: Map, - previous: ExtractedMessengerCapabilityType, - replacement: ExtractedMessengerCapabilityType, + previous: MessengerCapabilityPacket, + replacement: MessengerCapabilityPacket, ): void { const namespace = replacement.typeString.split(':')[0]; const group = byNamespace.get(namespace); @@ -380,10 +380,10 @@ function replaceDuplicateInGroup( * @returns The deduplicated and sorted namespace groups. */ function groupByNamespace( - items: ExtractedMessengerCapabilityType[], + items: MessengerCapabilityPacket[], ): NamespaceGroup[] { const byNamespace = new Map(); - const seen = new Map(); + const seen = new Map(); for (const item of items) { const existing = seen.get(item.typeString); diff --git a/packages/platform-api-docs/src/markdown.ts b/packages/platform-api-docs/src/markdown.ts index c38258bc78..ff4ca208f2 100644 --- a/packages/platform-api-docs/src/markdown.ts +++ b/packages/platform-api-docs/src/markdown.ts @@ -1,4 +1,4 @@ -import type { ExtractedMessengerCapabilityType, NamespaceGroup } from './types'; +import type { MessengerCapabilityPacket, NamespaceGroup } from './types'; /** * Convert backtick-quoted action/event names in text into links when they @@ -47,7 +47,7 @@ function linkifyReferences( * @returns The generated markdown string. */ export function generateItemMarkdown( - item: ExtractedMessengerCapabilityType, + item: MessengerCapabilityPacket, namespace: string, knownNames: Map, repoBaseUrl: string | null, diff --git a/packages/platform-api-docs/src/types.ts b/packages/platform-api-docs/src/types.ts index b2c3049ad4..464a684646 100644 --- a/packages/platform-api-docs/src/types.ts +++ b/packages/platform-api-docs/src/types.ts @@ -8,10 +8,10 @@ export type DocumentedParameter = { }; /** - * One messenger capability — an action or event registered with the platform - * — distilled from its TypeScript definition. + * Information about a messenger action or event extracted from its type + * in a source file. */ -export type ExtractedMessengerCapabilityType = { +export type MessengerCapabilityPacket = { /** The capability type's TypeScript identifier, e.g. `NetworkControllerGetStateAction`. */ typeName: string; /** The capability's messenger key, e.g. `NetworkController:getState`. */ @@ -43,8 +43,8 @@ export type ExtractedMessengerCapabilityType = { */ export type NamespaceGroup = { namespace: string; - actions: ExtractedMessengerCapabilityType[]; - events: ExtractedMessengerCapabilityType[]; + actions: MessengerCapabilityPacket[]; + events: MessengerCapabilityPacket[]; }; /**