From 2a31ad5c5b32daa594bfec7d59c68a26a2593ee4 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 3 Sep 2026 10:35:39 +0200 Subject: [PATCH 1/4] fix(platform-api-docs): prefer source over build output when deduplicating --- packages/platform-api-docs/CHANGELOG.md | 4 ++ .../platform-api-docs/src/generate.test.ts | 71 +++++++++++++++++++ packages/platform-api-docs/src/generate.ts | 30 ++++---- 3 files changed, 91 insertions(+), 14 deletions(-) diff --git a/packages/platform-api-docs/CHANGELOG.md b/packages/platform-api-docs/CHANGELOG.md index 86e64ccb0e0..f73d18a2b0e 100644 --- a/packages/platform-api-docs/CHANGELOG.md +++ b/packages/platform-api-docs/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Speed up documentation generation by loading source files in bulk instead of one at a time ([#9990](https://github.com/MetaMask/core/pull/9990)) - Bump `@metamask/utils` from `^11.11.0` to `^11.12.0` ([#10076](https://github.com/MetaMask/core/pull/10076)) +### Fixed + +- Link a capability to its source rather than to the build output compiled from it, where both are available + ## [0.1.0] ### Added diff --git a/packages/platform-api-docs/src/generate.test.ts b/packages/platform-api-docs/src/generate.test.ts index fa284232fc9..e1971a9c6ec 100644 --- a/packages/platform-api-docs/src/generate.test.ts +++ b/packages/platform-api-docs/src/generate.test.ts @@ -338,6 +338,77 @@ export type FooMessenger = Messenger<'Foo', FooAction, never>; }); }); + it('deduplicates items preferring source over build output', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + // Reproduces how build output wins in a real monorepo. `a-controller` is + // scanned before `b-controller`, and its import of `@metamask/b-controller` + // resolves through node_modules to the published `.d.cts`, so the first + // declaration seen for `B:get` is the built one. The source declaration is + // reached later, when `b-controller/src` is scanned, and should win. + const declaration = ` +/** Gets b. */ +export type BGetAction = { + type: 'B:get'; + handler: () => string; +}; +`; + + const bSrc = path.join(directoryPath, 'packages', 'b-controller', 'src'); + await fs.promises.mkdir(bSrc, { recursive: true }); + await fs.promises.writeFile( + path.join(bSrc, 'BController.ts'), + `${declaration} +export type BMessenger = Messenger<'B', BGetAction, never>; +`, + ); + + const bDist = path.join( + directoryPath, + 'node_modules', + '@metamask', + 'b-controller', + 'dist', + ); + await fs.promises.mkdir(bDist, { recursive: true }); + await fs.promises.writeFile(path.join(bDist, 'index.d.cts'), declaration); + await fs.promises.writeFile( + path.join(bDist, '..', 'package.json'), + JSON.stringify({ + name: '@metamask/b-controller', + types: './dist/index.d.cts', + }), + ); + + const aSrc = path.join(directoryPath, 'packages', 'a-controller', 'src'); + await fs.promises.mkdir(aSrc, { recursive: true }); + await fs.promises.writeFile( + path.join(aSrc, 'AController.ts'), + ` +import type { BGetAction } from '@metamask/b-controller'; + +export type AMessenger = Messenger<'A', BGetAction, never>; +`, + ); + + const outputDir = path.join(directoryPath, '.docs'); + await generate({ + projectPath: directoryPath, + outputDir, + strategy: 'scan', + scanDirs: ['src'], + }); + + const actionsMd = await fs.promises.readFile( + path.join(outputDir, 'docs', 'B', 'actions.md'), + 'utf8', + ); + expect(actionsMd).toContain('packages/b-controller/src/BController.ts'); + expect(actionsMd).not.toContain('/dist/'); + }); + }); + it('returns zero counts for project with no messenger types', async () => { expect.assertions(3); diff --git a/packages/platform-api-docs/src/generate.ts b/packages/platform-api-docs/src/generate.ts index 61e7ecdf7ca..773be716908 100644 --- a/packages/platform-api-docs/src/generate.ts +++ b/packages/platform-api-docs/src/generate.ts @@ -105,7 +105,14 @@ function deduplicationScore(item: MessengerCapabilityPacket): number { item.sourceFile.toLowerCase().includes(namespacePrefix) ? 1 : 0; - return jsDocScore + homeScore; + // A capability declared in a package's own source is usually also visible in + // the `dist` built from it, and a cross-package import resolves to that + // `dist` rather than to the sibling's source. Prefer the source, which is + // what an engineer can actually read and edit. Projects that only ever see + // published packages score every candidate the same way, so nothing changes + // for them. + const sourceScore = /[\\/]dist[\\/]/u.test(item.sourceFile) ? 0 : 1; + return jsDocScore + homeScore + sourceScore; } const execFileAsync = promisify(execFile); @@ -353,16 +360,11 @@ async function scanSources( sources: ScanSources, ): Promise { const project = createProject(); - const sourceFiles = []; + const patterns: string[] = []; for (const dir of sources.scanDirs) { const root = await toGlobPath(projectPath, dir); - sourceFiles.push( - ...addSourceFiles(project, [ - `${root}/**/*.ts`, - ...buildTsSourceExclusions(root), - ]), - ); + patterns.push(`${root}/**/*.ts`, ...buildTsSourceExclusions(root)); } if (sources.packagesDir) { @@ -370,19 +372,19 @@ async function scanSources( // 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), - ]), + patterns.push( + `${contentRoot}/**/*.ts`, + ...buildTsSourceExclusions(contentRoot), ); } if (sources.nodeModulesDir) { const root = await toGlobPath(sources.nodeModulesDir); - sourceFiles.push(...addSourceFiles(project, [`${root}/*/dist/**/*.d.cts`])); + patterns.push(`${root}/*/dist/**/*.d.cts`); } + const sourceFiles = addSourceFiles(project, patterns); + // 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. From 0b32e92e6782f5003f2924785ab113ca467c52c3 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Thu, 3 Sep 2026 10:40:04 +0200 Subject: [PATCH 2/4] docs(platform-api-docs): add PR link to changelog entry --- packages/platform-api-docs/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/platform-api-docs/CHANGELOG.md b/packages/platform-api-docs/CHANGELOG.md index f73d18a2b0e..d1980c49e6e 100644 --- a/packages/platform-api-docs/CHANGELOG.md +++ b/packages/platform-api-docs/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Link a capability to its source rather than to the build output compiled from it, where both are available +- Link a capability to its source rather than to the build output compiled from it, where both are available ([#10085](https://github.com/MetaMask/core/pull/10085)) ## [0.1.0] From b91c870b187115224c38e9e0b65fd2b746847b49 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Tue, 22 Sep 2026 19:26:55 +0200 Subject: [PATCH 3/4] fix(platform-api-docs): collect each source location in its own call --- .../platform-api-docs/src/generate.test.ts | 54 +++++++++++++++++++ packages/platform-api-docs/src/generate.ts | 25 ++++++--- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/packages/platform-api-docs/src/generate.test.ts b/packages/platform-api-docs/src/generate.test.ts index e1971a9c6ec..fa7d5655345 100644 --- a/packages/platform-api-docs/src/generate.test.ts +++ b/packages/platform-api-docs/src/generate.test.ts @@ -227,6 +227,60 @@ export type TestMessenger = Messenger<'Test', TestGetAction, never>; }); }); + it('keeps published declaration files when a scan directory contains node_modules', async () => { + expect.assertions(2); + + await withinSandbox(async ({ directoryPath }) => { + // Scanning `.` makes the project root a scan directory, so its + // `node_modules` exclusion covers the published declaration files too. + // Each location has to be collected in its own call for them to survive, + // since exclusions apply to every pattern in the call they belong to. + const srcDir = path.join(directoryPath, 'src'); + await fs.promises.mkdir(srcDir, { recursive: true }); + await fs.promises.writeFile( + path.join(srcDir, 'Local.ts'), + ` +export type LocalGetAction = { + type: 'Local:get'; + handler: () => void; +}; + +export type LocalMessenger = Messenger<'Local', LocalGetAction, never>; +`, + ); + + const distDir = path.join( + directoryPath, + 'node_modules', + '@metamask', + 'published-pkg', + 'dist', + ); + await fs.promises.mkdir(distDir, { recursive: true }); + await fs.promises.writeFile( + path.join(distDir, 'index.d.cts'), + ` +export type PublishedGetAction = { + type: 'Published:get'; + handler: () => void; +}; + +export type PublishedMessenger = Messenger<'Published', PublishedGetAction, never>; +`, + ); + + const result = await generate({ + projectPath: directoryPath, + outputDir: path.join(directoryPath, '.docs'), + strategy: 'scan', + scanDirs: ['.'], + }); + + expect(result.namespaces).toBe(2); + expect(result.actions).toBe(2); + }); + }); + it('follows symlinked @metamask packages when scanning node_modules/@metamask/*/dist', async () => { expect.assertions(1); diff --git a/packages/platform-api-docs/src/generate.ts b/packages/platform-api-docs/src/generate.ts index 773be716908..50f1314b717 100644 --- a/packages/platform-api-docs/src/generate.ts +++ b/packages/platform-api-docs/src/generate.ts @@ -360,11 +360,20 @@ async function scanSources( sources: ScanSources, ): Promise { const project = createProject(); - const patterns: string[] = []; + const sourceFiles = []; + // One call per location, not one call carrying every pattern. Exclusions + // apply to a whole call, so combining them lets the source-tree + // `node_modules` and `dist` exclusions match the published declaration files + // and drop them. for (const dir of sources.scanDirs) { const root = await toGlobPath(projectPath, dir); - patterns.push(`${root}/**/*.ts`, ...buildTsSourceExclusions(root)); + sourceFiles.push( + ...addSourceFiles(project, [ + `${root}/**/*.ts`, + ...buildTsSourceExclusions(root), + ]), + ); } if (sources.packagesDir) { @@ -372,19 +381,19 @@ async function scanSources( // 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`; - patterns.push( - `${contentRoot}/**/*.ts`, - ...buildTsSourceExclusions(contentRoot), + sourceFiles.push( + ...addSourceFiles(project, [ + `${contentRoot}/**/*.ts`, + ...buildTsSourceExclusions(contentRoot), + ]), ); } if (sources.nodeModulesDir) { const root = await toGlobPath(sources.nodeModulesDir); - patterns.push(`${root}/*/dist/**/*.d.cts`); + sourceFiles.push(...addSourceFiles(project, [`${root}/*/dist/**/*.d.cts`])); } - const sourceFiles = addSourceFiles(project, patterns); - // 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. From 38afbc9dd94a4c5a7370c0d466ace783bc367648 Mon Sep 17 00:00:00 2001 From: Salah-Eddine Saakoun Date: Wed, 23 Sep 2026 20:57:41 +0200 Subject: [PATCH 4/4] docs(platform-api-docs): clarify why sources are collected per location --- packages/platform-api-docs/src/generate.test.ts | 4 ---- packages/platform-api-docs/src/generate.ts | 7 +++---- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/platform-api-docs/src/generate.test.ts b/packages/platform-api-docs/src/generate.test.ts index fa7d5655345..abca66f1fcf 100644 --- a/packages/platform-api-docs/src/generate.test.ts +++ b/packages/platform-api-docs/src/generate.test.ts @@ -231,10 +231,6 @@ export type TestMessenger = Messenger<'Test', TestGetAction, never>; expect.assertions(2); await withinSandbox(async ({ directoryPath }) => { - // Scanning `.` makes the project root a scan directory, so its - // `node_modules` exclusion covers the published declaration files too. - // Each location has to be collected in its own call for them to survive, - // since exclusions apply to every pattern in the call they belong to. const srcDir = path.join(directoryPath, 'src'); await fs.promises.mkdir(srcDir, { recursive: true }); await fs.promises.writeFile( diff --git a/packages/platform-api-docs/src/generate.ts b/packages/platform-api-docs/src/generate.ts index 50f1314b717..494190d1894 100644 --- a/packages/platform-api-docs/src/generate.ts +++ b/packages/platform-api-docs/src/generate.ts @@ -362,10 +362,9 @@ async function scanSources( const project = createProject(); const sourceFiles = []; - // One call per location, not one call carrying every pattern. Exclusions - // apply to a whole call, so combining them lets the source-tree - // `node_modules` and `dist` exclusions match the published declaration files - // and drop them. + // NOTE: We are calling `addSourceFiles` for each kind of source instead of + // calling it at the very end so that at each step we can make sure to exclude + // `node_modules` and `dist`. for (const dir of sources.scanDirs) { const root = await toGlobPath(projectPath, dir); sourceFiles.push(