diff --git a/packages/platform-api-docs/CHANGELOG.md b/packages/platform-api-docs/CHANGELOG.md index dc6fb9d7a20..eef0f2441d3 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), [#9913](https://github.com/MetaMask/core/pull/9913)) +- Initial release of the platform-api-docs package ([#8012](https://github.com/MetaMask/core/pull/8012), [#9913](https://github.com/MetaMask/core/pull/9913), [#9990](https://github.com/MetaMask/core/pull/9990)) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/platform-api-docs/package.json b/packages/platform-api-docs/package.json index cc4fd9803b8..21921808055 100644 --- a/packages/platform-api-docs/package.json +++ b/packages/platform-api-docs/package.json @@ -49,7 +49,6 @@ "@mdx-js/react": "^3.1.1", "@metamask/utils": "^11.11.0", "execa": "^5.0.0", - "glob": "^13.0.6", "npm-which": "^3.0.1", "prism-react-renderer": "^2.4.1", "react": "^19.0.0", diff --git a/packages/platform-api-docs/src/discovery.test.ts b/packages/platform-api-docs/src/discovery.test.ts deleted file mode 100644 index 0805d676459..00000000000 --- a/packages/platform-api-docs/src/discovery.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { createSandbox } from '@metamask/utils/node'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -import { findDtsFiles, findTsFiles } from './discovery.js'; - -const { withinSandbox } = createSandbox('platform-api-docs/discovery'); - -describe('findTsFiles', () => { - it('finds .ts files in a directory', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - await fs.promises.writeFile( - path.join(directoryPath, 'Controller.ts'), - 'export class Controller {}', - ); - - const files = await findTsFiles(directoryPath); - - expect(files).toStrictEqual([path.join(directoryPath, 'Controller.ts')]); - }); - }); - - it('finds .ts files in nested subdirectories', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - const subDir = path.join(directoryPath, 'sub'); - await fs.promises.mkdir(subDir); - await fs.promises.writeFile( - path.join(subDir, 'Nested.ts'), - 'export class Nested {}', - ); - - const files = await findTsFiles(directoryPath); - - expect(files).toStrictEqual([path.join(subDir, 'Nested.ts')]); - }); - }); - - it('skips node_modules directories', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - const nmDir = path.join(directoryPath, 'node_modules', 'pkg'); - await fs.promises.mkdir(nmDir, { recursive: true }); - await fs.promises.writeFile( - path.join(nmDir, 'index.ts'), - 'export default {}', - ); - - const files = await findTsFiles(directoryPath); - - expect(files).toStrictEqual([]); - }); - }); - - it('skips dist directories', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - const distDir = path.join(directoryPath, 'dist'); - await fs.promises.mkdir(distDir); - await fs.promises.writeFile( - path.join(distDir, 'index.ts'), - 'export default {}', - ); - - const files = await findTsFiles(directoryPath); - - expect(files).toStrictEqual([]); - }); - }); - - it('skips test directories (__tests__, tests, test, __mocks__)', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - for (const dir of ['__tests__', 'tests', 'test', '__mocks__']) { - const testDir = path.join(directoryPath, dir); - await fs.promises.mkdir(testDir); - await fs.promises.writeFile(path.join(testDir, 'file.ts'), 'export {}'); - } - - const files = await findTsFiles(directoryPath); - - expect(files).toStrictEqual([]); - }); - }); - - it('skips test files (.test.ts, .test-d.ts, .spec.ts)', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - await fs.promises.writeFile( - path.join(directoryPath, 'Controller.test.ts'), - 'describe("test", () => {})', - ); - await fs.promises.writeFile( - path.join(directoryPath, 'Controller.test-d.ts'), - 'export {}', - ); - await fs.promises.writeFile( - path.join(directoryPath, 'Controller.spec.ts'), - 'export {}', - ); - - const files = await findTsFiles(directoryPath); - - expect(files).toStrictEqual([]); - }); - }); - - it('skips declaration files (.d.ts)', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - await fs.promises.writeFile( - path.join(directoryPath, 'types.d.ts'), - 'declare module "foo" {}', - ); - - const files = await findTsFiles(directoryPath); - - expect(files).toStrictEqual([]); - }); - }); - - it('returns empty array for empty directory', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - const files = await findTsFiles(directoryPath); - - expect(files).toStrictEqual([]); - }); - }); -}); - -describe('findDtsFiles', () => { - it('finds .d.cts files in a directory', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - await fs.promises.writeFile( - path.join(directoryPath, 'index.d.cts'), - 'export declare const foo: string;', - ); - - const files = await findDtsFiles(directoryPath); - - expect(files).toStrictEqual([path.join(directoryPath, 'index.d.cts')]); - }); - }); - - it('finds .d.cts files in nested subdirectories', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - const subDir = path.join(directoryPath, 'sub'); - await fs.promises.mkdir(subDir); - await fs.promises.writeFile( - path.join(subDir, 'types.d.cts'), - 'export declare const bar: number;', - ); - - const files = await findDtsFiles(directoryPath); - - expect(files).toStrictEqual([path.join(subDir, 'types.d.cts')]); - }); - }); - - it('skips nested node_modules directories', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - const nmDir = path.join(directoryPath, 'node_modules', 'pkg'); - await fs.promises.mkdir(nmDir, { recursive: true }); - await fs.promises.writeFile( - path.join(nmDir, 'index.d.cts'), - 'export declare const baz: boolean;', - ); - - const files = await findDtsFiles(directoryPath); - - expect(files).toStrictEqual([]); - }); - }); - - it('returns empty array for empty directory', async () => { - expect.assertions(1); - - await withinSandbox(async ({ directoryPath }) => { - const files = await findDtsFiles(directoryPath); - - expect(files).toStrictEqual([]); - }); - }); -}); diff --git a/packages/platform-api-docs/src/discovery.ts b/packages/platform-api-docs/src/discovery.ts deleted file mode 100644 index 073a389280a..00000000000 --- a/packages/platform-api-docs/src/discovery.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { glob } from 'glob'; - -/** - * Find all non-test TypeScript source files in a directory. - * Skips node_modules, dist, test directories, and declaration files. - * - * Results are sorted lexicographically so that downstream consumers - * (extraction, deduplication, output ordering) behave deterministically - * across filesystems. - * - * @param dir - The directory to search. - * @returns A promise that resolves to a sorted array of absolute file paths. - */ -export async function findTsFiles(dir: string): Promise { - const matches = await glob('**/*.ts', { - cwd: dir, - absolute: true, - ignore: [ - '**/node_modules/**', - '**/dist/**', - '**/__tests__/**', - '**/tests/**', - '**/test/**', - '**/__mocks__/**', - '**/*.test.ts', - '**/*.test-d.ts', - '**/*.spec.ts', - '**/*.d.ts', - ], - }); - return matches.sort(); -} - -/** - * Find all `.d.cts` declaration files in a directory. - * Skips nested node_modules subdirectories. See {@link findTsFiles} for the - * note about sorting. - * - * @param dir - The directory to search. - * @returns A promise that resolves to a sorted array of absolute file paths. - */ -export async function findDtsFiles(dir: string): Promise { - const matches = await glob('**/*.d.cts', { - cwd: dir, - absolute: true, - ignore: ['**/node_modules/**'], - }); - return matches.sort(); -} diff --git a/packages/platform-api-docs/src/extraction.test.ts b/packages/platform-api-docs/src/extraction.test.ts index 50d9ab4df09..a2149dcfb76 100644 --- a/packages/platform-api-docs/src/extraction.test.ts +++ b/packages/platform-api-docs/src/extraction.test.ts @@ -2,10 +2,8 @@ import { createSandbox } from '@metamask/utils/node'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { - createExtractionProject, - extractFromSourceFile, -} from './extraction.js'; +import { extractFromSourceFile } from './extraction.js'; +import { createProject } from './ts-project.js'; import { MessengerCapabilityPacket } from './types.js'; const { withinSandbox } = createSandbox('platform-api-docs/extraction'); @@ -36,7 +34,7 @@ function withMessenger( * run `extractFromSourceFile` on it. * * This mirrors the logic that callers of the library use in production (via - * `createExtractionProject` + `extractFromSourceFile`) without going through + * `createProject` + `extractFromSourceFile`) without going through * the now-removed `extractFromFile` convenience wrapper. * * @param filePath - Absolute path of the file to write and extract from. @@ -52,7 +50,7 @@ async function extractFromWrittenFile( ): Promise { await fs.promises.writeFile(filePath, content); const parentDir = path.dirname(filePath); - const project = createExtractionProject(); + const project = createProject(); project.addSourceFilesAtPaths([ path.join(parentDir, '**/*.ts'), path.join(parentDir, '**/*.d.cts'), diff --git a/packages/platform-api-docs/src/extraction.ts b/packages/platform-api-docs/src/extraction.ts index 5b3c2f43d37..b2afa22d2eb 100644 --- a/packages/platform-api-docs/src/extraction.ts +++ b/packages/platform-api-docs/src/extraction.ts @@ -13,7 +13,7 @@ import type { TypeNode, TypeReferenceNode, } from 'ts-morph'; -import { Node as NodeGuards, Project, ts } from 'ts-morph'; +import { Node as NodeGuards } from 'ts-morph'; import type { MessengerCapabilityPacket, @@ -929,32 +929,6 @@ function tryToExtractFromCapabilityTypeConstructor( // Public entry points // --------------------------------------------------------------------------- -/** - * Create a ts-morph Project configured for messenger-docs extraction. The - * caller should add every source file that may be referenced (directly or - * transitively) before calling {@link extractFromSourceFile}, so the type - * checker can resolve cross-file references. - * - * @returns A new ts-morph Project. - */ -export function createExtractionProject(): Project { - return new Project({ - compilerOptions: { - allowJs: false, - noEmit: true, - // Match the project's permissive defaults — we just need symbol - // resolution, not full typechecking. - strict: false, - skipLibCheck: true, - // Explicit module options so cross-file symbol resolution works - // regardless of the host process's tsconfig. - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.NodeJs, - }, - }); -} - /** * Extract information (action/event type string, handler/payload arguments and * return type, etc.) about every messenger action or event type which is diff --git a/packages/platform-api-docs/src/generate.test.ts b/packages/platform-api-docs/src/generate.test.ts index fcf55d9bed1..fa284232fc9 100644 --- a/packages/platform-api-docs/src/generate.test.ts +++ b/packages/platform-api-docs/src/generate.test.ts @@ -144,6 +144,53 @@ export type MyMessenger = Messenger<'My', MyGetAction, never>; }); }); + it('scans a package whose name collides with an exclusion pattern', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + // The exclusions drop directories named `test`, `dist` and friends. They + // must be anchored at each package's `src`, not at `packages`, or a + // package that happens to be *called* `test` is dropped whole. + const pkgSrc = path.join(directoryPath, 'packages', 'test', 'src'); + await fs.promises.mkdir(pkgSrc, { recursive: true }); + await fs.promises.writeFile( + path.join(pkgSrc, 'Controller.ts'), + ` +export type TestPkgGetAction = { + type: 'TestPkg:get'; + handler: () => string; +}; + +export type TestPkgMessenger = Messenger<'TestPkg', TestPkgGetAction, never>; +`, + ); + // A genuine test directory nested inside that package is still excluded. + const nestedTestDir = path.join(pkgSrc, 'test'); + await fs.promises.mkdir(nestedTestDir, { recursive: true }); + await fs.promises.writeFile( + path.join(nestedTestDir, 'Helper.ts'), + ` +export type NestedGetAction = { + type: 'Nested:get'; + handler: () => string; +}; + +export type NestedMessenger = Messenger<'Nested', NestedGetAction, never>; +`, + ); + + const result = await generate({ + projectPath: directoryPath, + outputDir: path.join(directoryPath, '.docs'), + strategy: 'scan', + scanDirs: ['src'], + }); + + expect(result.actions).toBe(1); + expect(result.namespaces).toBe(1); + }); + }); + it('scans node_modules/@metamask/*/dist/ for .d.cts files', async () => { expect.assertions(1); @@ -403,8 +450,8 @@ export type GitMessenger = Messenger<'Git', GitGetAction, never>; }); }); - it('warns and continues when a single source file fails to read', async () => { - expect.assertions(2); + it('skips unreadable source files and still documents the rest', async () => { + expect.assertions(1); await withinSandbox(async ({ directoryPath }) => { const srcDir = path.join(directoryPath, 'src'); @@ -421,28 +468,21 @@ export type OkAction = { export type OkMessenger = Messenger<'Ok', OkAction, never>; `, ); - // A broken symlink pointing nowhere. Discovery surfaces it (it's not a - // directory), but reading it throws ENOENT — exercising the per-file - // failure path in `extractFromDirectory`. + // A broken symlink pointing nowhere. It matches `**‍/*.ts` by name, but + // cannot be read, so it must not stop the valid file being documented. await fs.promises.symlink( '/this/path/does/not/exist', path.join(srcDir, 'Bad.ts'), ); - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); - try { - const result = await generate({ - projectPath: directoryPath, - outputDir: path.join(directoryPath, '.docs'), - strategy: 'scan', - scanDirs: ['src'], - }); + const result = await generate({ + projectPath: directoryPath, + outputDir: path.join(directoryPath, '.docs'), + strategy: 'scan', + scanDirs: ['src'], + }); - expect(result.actions).toBe(1); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Bad.ts')); - } finally { - warnSpy.mockRestore(); - } + expect(result.actions).toBe(1); }); }); diff --git a/packages/platform-api-docs/src/generate.ts b/packages/platform-api-docs/src/generate.ts index 4babf56046d..e09ed2f4b97 100644 --- a/packages/platform-api-docs/src/generate.ts +++ b/packages/platform-api-docs/src/generate.ts @@ -5,11 +5,7 @@ import * as path from 'node:path'; import { promisify } from 'node:util'; import type { Project } from 'ts-morph'; -import { findDtsFiles, findTsFiles } from './discovery.js'; -import { - createExtractionProject, - extractFromSourceFile, -} from './extraction.js'; +import { extractFromSourceFile } from './extraction.js'; import { generateIndexPage, generateNamespacePage, @@ -17,6 +13,7 @@ import { } from './markdown.js'; import type { RootCapabilitiesTypeReference } from './root-messenger-discovery.js'; import { discoverFromRootMessengerCapabilitiesTypes } from './root-messenger-discovery.js'; +import { createProject } from './ts-project.js'; import type { MessengerCapabilityPacket, NamespaceGroup } from './types.js'; /** How many skipped capability types to name before summarizing the rest. */ @@ -253,71 +250,105 @@ function logScanPlan(sources: ScanSources): void { } /** - * Run extraction against every file in a single directory, logging and - * swallowing per-file failures. All files are added to the shared `project` - * up front so the type checker can resolve cross-file references when the - * walker descends into imported types. + * Patterns excluded when scanning TypeScript sources: build output, tests, and + * declaration files (which are only read under `node_modules/@metamask`, via + * the separate set below). + * + * Every pattern is anchored to `root` rather than written as a bare + * `!**‍/*.test.ts`. A matcher resolves an unanchored negation against the + * process's working directory, not against the pattern it accompanies, so an + * unanchored exclusion silently stops excluding anything the moment the scanned + * path falls outside the working directory — which is the normal case, since + * this runs from wherever the consumer invoked it. + * + * `contentRoot` must be the directory the matched *files* live under, not an + * ancestor of it. Anchoring at `packages/` rather than `packages/*‍/src` would + * make the first path segment a package name, so a workspace package called + * `test` or `dist` would match `test/**` and be dropped whole. + * + * @param contentRoot - Resolved directory, or directory glob, that the matched + * files live directly under. + * @returns The exclusion patterns. + */ +function buildTsSourceExclusions(contentRoot: string): string[] { + return [ + 'node_modules/**', + 'dist/**', + '__tests__/**', + 'tests/**', + 'test/**', + '__mocks__/**', + '*.test.ts', + '*.test-d.ts', + '*.spec.ts', + '*.d.ts', + ].map((pattern) => `!${contentRoot}/**/${pattern}`); +} + +/** + * Patterns excluded when reading published declaration files: dependencies + * vendored inside a package's own `dist`. + * + * Deliberately narrower than {@link buildTsSourceExclusions}. A blanket `dist/**` + * exclusion would match the very `dist` segment these files live under and + * silently drop every one of them. + * + * @param root - Resolved `node_modules/@metamask` directory. + * @returns The exclusion patterns. + */ +function buildDeclarationFileExclusions(root: string): string[] { + return [`!${root}/*/dist/**/node_modules/**`]; +} + +/** + * Add every file matching a set of glob patterns to the project, in a stable + * order. + * + * ts-morph promises nothing about the order it returns matches in, and + * deduplication downstream keeps the first of two equally-scored items, so an + * unsorted list would let the filesystem decide which source link a capability + * gets. + * + * Ordering is by code unit rather than `localeCompare`, which collates + * differently depending on the locale the process happens to run under. * * @param project - The shared ts-morph project. - * @param directory - The directory to scan. - * @param projectPath - The project root, used for relative path display. - * @param findFiles - The function used to enumerate files in the directory. - * @returns The list of extracted messenger items. + * @param patterns - Glob patterns to match, including `!` exclusions. + * @returns The added source files, sorted by path. */ -async function extractFromDirectory( +function addSourceFiles( project: Project, - directory: string, - projectPath: string, - findFiles: (dir: string) => Promise, -): Promise { - const items: MessengerCapabilityPacket[] = []; - const files = await findFiles(directory); - for (const file of files) { - try { - const sourceFile = - project.getSourceFile(file) ?? project.addSourceFileAtPath(file); - items.push(...extractFromSourceFile(sourceFile, projectPath)); - } catch (error) { - console.warn( - `Warning: failed to parse ${path.relative(projectPath, file)}`, - ); - console.warn(error); - } - } - return items; + patterns: string[], +): ReturnType { + return project + .addSourceFilesAtPaths(patterns) + .sort( + (fileA, fileB) => + // Subtracting the two comparisons keeps this branchless, so it reads + // the same whichever order the matcher happened to return. + Number(fileA.getFilePath() > fileB.getFilePath()) - + Number(fileA.getFilePath() < fileB.getFilePath()), + ); } /** - * Enumerate the subdirectories of a parent directory that match the expected - * layout (e.g., `packages/*‍/src` or `node_modules/@metamask/*‍/dist`), keeping - * only those that actually exist. + * Build a glob pattern from a directory path. + * + * Two things have to be true of the result. Glob syntax is always + * forward-slashed, including on Windows, where `path.join` would produce + * backslashes that a matcher reads as escapes. And the path must be fully + * resolved: the matcher does not follow a symlinked *ancestor* of the pattern, + * so a project under `/tmp` or `/var` on macOS (both symlinks) would match + * nothing at all. * - * @param parentDir - The parent directory to enumerate. - * @param subPath - The trailing path component appended to each entry. - * @param includeSymlinks - Whether to include symbolic links (true for - * node_modules where workspaces are symlinked). - * @returns The list of absolute paths to existing target subdirectories. + * @param segments - Path segments to join. + * @returns The joined, resolved path with forward slashes. */ -async function listTargetSubdirectories( - parentDir: string, - subPath: string, - includeSymlinks: boolean, -): Promise { - const entries = await fs.readdir(parentDir, { withFileTypes: true }); - const candidates = entries - .filter( - (entry) => - entry.isDirectory() || (includeSymlinks && entry.isSymbolicLink()), - ) - .map((entry) => path.join(parentDir, entry.name, subPath)); - - const existing: string[] = []; - for (const candidate of candidates) { - if (await directoryExists(candidate)) { - existing.push(candidate); - } - } - return existing; +async function toGlobPath(...segments: string[]): Promise { + // Safe to resolve without a fallback: `discoverScanSources` has already + // confirmed every directory reaching this point exists. + const resolved = await fs.realpath(path.join(...segments)); + return resolved.replace(/\\/gu, '/'); } /** @@ -327,6 +358,10 @@ async function listTargetSubdirectories( * declaration in one file walking through an imported umbrella union into * an auto-generated `*-method-action-types.ts` sibling). * + * Locations are scanned in the order the previous implementation used — scan + * directories, then workspace packages, then published declaration files — as + * deduplication resolves ties in favour of whichever item it saw first. + * * @param projectPath - The project root path. * @param sources - The set of source locations to scan. * @returns A flat list of all extracted messenger items. @@ -335,56 +370,63 @@ async function scanSources( projectPath: string, sources: ScanSources, ): Promise { - const project = createExtractionProject(); - const allItems: MessengerCapabilityPacket[] = []; + const project = createProject(); + const sourceFiles = []; for (const dir of sources.scanDirs) { - allItems.push( - ...(await extractFromDirectory( - project, - path.join(projectPath, dir), - projectPath, - findTsFiles, - )), + const root = await toGlobPath(projectPath, dir); + sourceFiles.push( + ...addSourceFiles(project, [ + `${root}/**/*.ts`, + ...buildTsSourceExclusions(root), + ]), ); } if (sources.packagesDir) { - const srcDirs = await listTargetSubdirectories( - sources.packagesDir, - 'src', - false, + const root = await toGlobPath(sources.packagesDir); + // Anchored at each package's `src`, not at `packages` itself, so a package + // whose name collides with an exclusion (`test`, `dist`) isn't dropped. + const contentRoot = `${root}/*/src`; + sourceFiles.push( + ...addSourceFiles(project, [ + `${contentRoot}/**/*.ts`, + ...buildTsSourceExclusions(contentRoot), + ]), ); - for (const srcDir of srcDirs) { - allItems.push( - ...(await extractFromDirectory( - project, - srcDir, - projectPath, - findTsFiles, - )), - ); - } } if (sources.nodeModulesDir) { - const distDirs = await listTargetSubdirectories( - sources.nodeModulesDir, - 'dist', - true, + const root = await toGlobPath(sources.nodeModulesDir); + sourceFiles.push( + ...addSourceFiles(project, [ + `${root}/*/dist/**/*.d.cts`, + ...buildDeclarationFileExclusions(root), + ]), ); - for (const distDir of distDirs) { - allItems.push( - ...(await extractFromDirectory( - project, - distDir, - projectPath, - findDtsFiles, - )), + } + + // Matched paths are fully resolved, so the root they are made relative to + // has to be resolved the same way or every source link becomes a `../..` + // walk out of the project. + const resolvedProjectPath = await fs.realpath(projectPath); + + const allItems: MessengerCapabilityPacket[] = []; + for (const sourceFile of sourceFiles) { + try { + allItems.push(...extractFromSourceFile(sourceFile, resolvedProjectPath)); + } catch (error) { + // istanbul ignore next: defensive. Files that can't be read or parsed + // are dropped by the matcher before they reach here, so this only + // catches a file whose types defeat the extractor — worth surviving + // when scanning thousands of files, but not reproducible in a test. + console.warn( + `Warning: failed to parse ${path.relative(resolvedProjectPath, sourceFile.getFilePath())}`, ); + // istanbul ignore next: see above. + console.warn(error); } } - return allItems; } diff --git a/packages/platform-api-docs/src/root-messenger-discovery.ts b/packages/platform-api-docs/src/root-messenger-discovery.ts index ef4415a3adf..9079720fa8a 100644 --- a/packages/platform-api-docs/src/root-messenger-discovery.ts +++ b/packages/platform-api-docs/src/root-messenger-discovery.ts @@ -5,12 +5,13 @@ import type { Type, TypeAliasDeclaration, } from 'ts-morph'; -import { Node as NodeGuards, Project, ts } from 'ts-morph'; +import { Node as NodeGuards } from 'ts-morph'; import { classifyMessengerCapabilityTypeDeclaration, extractFromMessengerCapabilityTypeDeclaration, } from './extraction.js'; +import { createProject } from './ts-project.js'; import type { MessengerCapabilityPacket } from './types.js'; // --------------------------------------------------------------------------- @@ -80,29 +81,6 @@ export function parseRootCapabilitiesTypeReference( 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 @@ -131,11 +109,11 @@ function resolveMessengerCapabilitiesTypeReference({ }): TypeAliasDeclaration { const absolutePath = path.resolve(projectPath, reference.filePath); + // `addSourceFileAtPath` is idempotent: the two references often name the same + // file, and the second call returns the source file added by the first. let sourceFile; try { - sourceFile = - project.getSourceFile(absolutePath) ?? - project.addSourceFileAtPath(absolutePath); + sourceFile = project.addSourceFileAtPath(absolutePath); } catch { throw new Error( `Could not read ${absolutePath}, which was named by ${commandLineOptionName}.`, @@ -477,7 +455,7 @@ export function discoverFromRootMessengerCapabilitiesTypes({ capabilityPackets: MessengerCapabilityPacket[]; skippedCapabilities: SkippedCapabilities; } { - const project = createRootMessengerProject(); + const project = createProject(); const capabilityPacketCollections = [ [rootActionsTypeReference, 'action', '--root-actions'], [rootEventsTypeReference, 'event', '--root-events'], diff --git a/packages/platform-api-docs/src/ts-project.ts b/packages/platform-api-docs/src/ts-project.ts new file mode 100644 index 00000000000..0a15b3cf492 --- /dev/null +++ b/packages/platform-api-docs/src/ts-project.ts @@ -0,0 +1,29 @@ +import { Project, ts } from 'ts-morph'; + +/** + * Create a ts-morph Project configured for reading messenger capability types. + * + * Both discovery strategies share this: `scan` adds every file it can find so + * the checker can resolve cross-file references, while `root-messenger` adds + * only the entry files and lets the checker pull in the rest. + * + * @returns A new ts-morph Project. + */ +export function createProject(): Project { + return new Project({ + compilerOptions: { + allowJs: false, + noEmit: true, + // Match the project's permissive defaults — we only 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, + // Explicit module options so cross-file symbol resolution works + // regardless of the host process's tsconfig. + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + }, + }); +} diff --git a/yarn.lock b/yarn.lock index 41d3a320eef..ce0c8c8a782 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8490,7 +8490,6 @@ __metadata: "@types/yargs": "npm:^17.0.32" deepmerge: "npm:^4.2.2" execa: "npm:^5.0.0" - glob: "npm:^13.0.6" jest: "npm:^30.4.2" npm-which: "npm:^3.0.1" prism-react-renderer: "npm:^2.4.1" @@ -18239,17 +18238,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:^13.0.6": - version: 13.0.6 - resolution: "glob@npm:13.0.6" - dependencies: - minimatch: "npm:^10.2.2" - minipass: "npm:^7.1.3" - path-scurry: "npm:^2.0.2" - checksum: 10/201ad69e5f0aa74e1d8c00a481581f8b8c804b6a4fbfabeeb8541f5d756932800331daeba99b58fb9e4cd67e12ba5a7eba5b82fb476691588418060b84353214 - languageName: node - linkType: hard - "glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" @@ -20813,13 +20801,6 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^11.0.0": - version: 11.5.1 - resolution: "lru-cache@npm:11.5.1" - checksum: 10/02c4f73967d91fb101f4accf8ebac9e0541e08e16d987bdb9e9737f13e5f2c4bc33c593b98ec30e4486bf899bc97edb36fbd133684b36087336559e41edafdea - languageName: node - linkType: hard - "lru-cache@npm:^5.1.1": version: 5.1.1 resolution: "lru-cache@npm:5.1.1" @@ -21940,7 +21921,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.0.1, minimatch@npm:^10.2.2, minimatch@npm:^10.2.5": +"minimatch@npm:^10.0.1, minimatch@npm:^10.2.5": version: 10.2.5 resolution: "minimatch@npm:10.2.5" dependencies: @@ -22056,7 +22037,7 @@ __metadata: languageName: node linkType: hard -"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3": +"minipass@npm:^5.0.0 || ^6.0.2 || ^7.0.0, minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2": version: 7.1.3 resolution: "minipass@npm:7.1.3" checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6 @@ -23241,16 +23222,6 @@ __metadata: languageName: node linkType: hard -"path-scurry@npm:^2.0.2": - version: 2.0.2 - resolution: "path-scurry@npm:2.0.2" - dependencies: - lru-cache: "npm:^11.0.0" - minipass: "npm:^7.1.2" - checksum: 10/2b4257422bcb870a4c2d205b3acdbb213a72f5e2250f61c80f79c9d014d010f82bdf8584441612c8e1fa4eb098678f5704a66fa8377d72646bad4be38e57a2c3 - languageName: node - linkType: hard - "path-to-regexp@npm:3.3.0": version: 3.3.0 resolution: "path-to-regexp@npm:3.3.0"