diff --git a/packages/kbn-docs-utils/src/build_api_docs_cli.test.ts b/packages/kbn-docs-utils/src/build_api_docs_cli.test.ts index 7dd4f139ac9a3..7b2b92c66d047 100644 --- a/packages/kbn-docs-utils/src/build_api_docs_cli.test.ts +++ b/packages/kbn-docs-utils/src/build_api_docs_cli.test.ts @@ -82,11 +82,13 @@ describe('build_api_docs_cli', () => { }); it('runs build flow when stats are not provided', async () => { + const mockPlugins = [ + { id: 'p1', manifest: { owner: { name: 'team' }, serviceFolders: [] }, isPlugin: true }, + ]; const setupResult = { project: {}, - plugins: [ - { id: 'p1', manifest: { owner: { name: 'team' }, serviceFolders: [] }, isPlugin: true }, - ], + plugins: mockPlugins, + allPlugins: mockPlugins, }; const apiMapResult = { pluginApiMap: {}, @@ -107,6 +109,7 @@ describe('build_api_docs_cli', () => { expect(buildApiMap).toHaveBeenCalledWith( setupResult.project, setupResult.plugins, + setupResult.allPlugins, log, mockTx, { stats: undefined, collectReferences: false } diff --git a/packages/kbn-docs-utils/src/build_api_docs_cli.ts b/packages/kbn-docs-utils/src/build_api_docs_cli.ts index 5f3a42709ba45..25ab26c60f740 100644 --- a/packages/kbn-docs-utils/src/build_api_docs_cli.ts +++ b/packages/kbn-docs-utils/src/build_api_docs_cli.ts @@ -85,6 +85,7 @@ export function runBuildApiDocsCli() { const apiMapResult = buildApiMap( setupResult.project, setupResult.plugins, + setupResult.allPlugins, log, transaction, options diff --git a/packages/kbn-docs-utils/src/check_package_docs_cli.run.test.ts b/packages/kbn-docs-utils/src/check_package_docs_cli.run.test.ts index 893bad4980a78..b884fc0ccd31c 100644 --- a/packages/kbn-docs-utils/src/check_package_docs_cli.run.test.ts +++ b/packages/kbn-docs-utils/src/check_package_docs_cli.run.test.ts @@ -90,7 +90,7 @@ describe('runCheckPackageDocs', () => { expect(reportMetrics).toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(log.error).toHaveBeenCalledWith( - expect.stringContaining('Validation failed for 1 plugin') + expect.stringContaining('Validation failed for 1 package') ); expect(mockTx.end).toHaveBeenCalled(); }); @@ -129,7 +129,7 @@ describe('runCheckPackageDocs', () => { await runCheckPackageDocs(log as any, { plugin: 'plugin-a' } as any); expect(process.exitCode).toBeUndefined(); - expect(log.info).toHaveBeenCalledWith('All plugins passed validation.'); + expect(log.info).toHaveBeenCalledWith('All packages passed validation.'); expect(mockTx.end).toHaveBeenCalled(); }); }); diff --git a/packages/kbn-docs-utils/src/check_package_docs_cli.ts b/packages/kbn-docs-utils/src/check_package_docs_cli.ts index 055a6bccb4521..489703bf4e8ba 100644 --- a/packages/kbn-docs-utils/src/check_package_docs_cli.ts +++ b/packages/kbn-docs-utils/src/check_package_docs_cli.ts @@ -129,6 +129,7 @@ export const runCheckPackageDocs = async (log: ToolingLog, flags: CliFlags) => { const apiMapResult = buildApiMap( setupResult.project, setupResult.plugins, + setupResult.allPlugins, log, transaction, optionsWithChecks @@ -160,13 +161,13 @@ export const runCheckPackageDocs = async (log: ToolingLog, flags: CliFlags) => { if (failingPlugins.length > 0) { log.error( - `Validation failed for ${failingPlugins.length} plugin(s): ${failingPlugins - .map((plugin) => plugin.pluginId) + `Validation failed for ${failingPlugins.length} package(s): ${failingPlugins + .map(({ pluginId }) => pluginId) .join(', ')}.` ); process.exitCode = 1; } else { - log.info('All plugins passed validation.'); + log.info('All packages passed validation.'); } } catch (error) { transaction?.setOutcome('failure'); diff --git a/packages/kbn-docs-utils/src/cli/tasks/build_api_map.test.ts b/packages/kbn-docs-utils/src/cli/tasks/build_api_map.test.ts index 495960ad9e59f..3c2da2fdfe245 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/build_api_map.test.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/build_api_map.test.ts @@ -67,9 +67,10 @@ describe('buildApiMap', () => { pluginFilter: ['test-plugin'], }; - buildApiMap(project, plugins, log, transaction, options); + const allPlugins = [...plugins, { id: 'other-plugin' }]; + buildApiMap(project, plugins, allPlugins, log, transaction, options); - expect(getPluginApiMap).toHaveBeenCalledWith(project, plugins, log, { + expect(getPluginApiMap).toHaveBeenCalledWith(project, plugins, allPlugins, log, { collectReferences: true, pluginFilter: ['test-plugin'], }); @@ -80,7 +81,7 @@ describe('buildApiMap', () => { collectReferences: false, }; - const result = buildApiMap(project, plugins, log, transaction, options); + const result = buildApiMap(project, plugins, plugins, log, transaction, options); expect(result).toBeDefined(); expect(result.pluginApiMap).toBeDefined(); @@ -95,7 +96,7 @@ describe('buildApiMap', () => { collectReferences: false, }; - buildApiMap(project, plugins, log, transaction, options); + buildApiMap(project, plugins, plugins, log, transaction, options); expect(transaction.startSpan).toHaveBeenCalledWith('build_api_docs.getPluginApiMap', 'setup'); }); diff --git a/packages/kbn-docs-utils/src/cli/tasks/build_api_map.ts b/packages/kbn-docs-utils/src/cli/tasks/build_api_map.ts index 50ea9ffc55f7a..d8b60f5ea9574 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/build_api_map.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/build_api_map.ts @@ -25,6 +25,7 @@ import type { BuildApiMapResult, CliOptions } from '../types'; * * @param project - TypeScript project instance. * @param plugins - List of plugins and packages to analyze. + * @param allPlugins - All plugins/packages for cross-reference resolution. * @param log - Tooling log instance. * @param transaction - APM transaction for tracking. * @param options - CLI options including collectReferences and pluginFilter. @@ -33,6 +34,7 @@ import type { BuildApiMapResult, CliOptions } from '../types'; export function buildApiMap( project: Project, plugins: PluginOrPackage[], + allPlugins: PluginOrPackage[], log: ToolingLog, transaction: Transaction, options: CliOptions @@ -46,7 +48,7 @@ export function buildApiMap( referencedDeprecations, adoptionTrackedAPIs, unnamedExports, - } = getPluginApiMap(project, plugins, log, { + } = getPluginApiMap(project, plugins, allPlugins, log, { collectReferences: options.collectReferences, pluginFilter: options.pluginFilter, }); diff --git a/packages/kbn-docs-utils/src/cli/tasks/collect_stats.test.ts b/packages/kbn-docs-utils/src/cli/tasks/collect_stats.test.ts index 72f8095161742..279aca334bd12 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/collect_stats.test.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/collect_stats.test.ts @@ -55,6 +55,7 @@ describe('collectStats', () => { plugins: [mockPlugin], pathsByPlugin: new Map([[mockPlugin, ['src/plugins/test/public/index.ts']]]), project: {} as any, + allPlugins: [mockPlugin], }; apiMapResult = { diff --git a/packages/kbn-docs-utils/src/cli/tasks/report_metrics.test.ts b/packages/kbn-docs-utils/src/cli/tasks/report_metrics.test.ts index 9baf46192ecf5..56a6a48812675 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/report_metrics.test.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/report_metrics.test.ts @@ -64,6 +64,7 @@ describe('reportMetrics', () => { plugins: [mockPlugin], pathsByPlugin: new Map(), project: {} as any, + allPlugins: [mockPlugin], }; apiMapResult = { diff --git a/packages/kbn-docs-utils/src/cli/tasks/report_metrics.ts b/packages/kbn-docs-utils/src/cli/tasks/report_metrics.ts index fc3d467712439..1bb3da436de88 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/report_metrics.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/report_metrics.ts @@ -16,10 +16,17 @@ import type { AllPluginStats, BuildApiMapResult, CliOptions, SetupProjectResult /** * Generates a link to the GitHub source for an API declaration. * + * TODO: clintandrewhall - allow `base` to be overridden in the instance of a CI build + * associated with a PR. + * * @param declaration - API declaration to generate link for. * @returns GitHub link to the source code. */ function getLink(declaration: ApiDeclaration): string { + const base = `https://github.com/elastic/kibana/blob/main/${declaration.path}`; + if (declaration.lineNumber) { + return `${base}#L${declaration.lineNumber}`; + } return `https://github.com/elastic/kibana/tree/main/${ declaration.path }#:~:text=${encodeURIComponent(declaration.label)}`; @@ -53,10 +60,38 @@ export function reportMetrics( const { missingApiItems, referencedDeprecations } = apiMapResult; const reporter = CiStatsReporter.fromEnv(log); + const printIssueTable = (title: string, rows: Array<{ id: string; link: string }>) => { + const count = rows.length; + if (count === 0) { + log.info(`${title}: none`); + return; + } + log.info(`${title} (${count})`); + // eslint-disable-next-line no-console + console.table(rows); + }; + + const printMissingExportsTable = ( + title: string, + entries: Array<{ source: string; references: string }> + ) => { + const header = title.toUpperCase(); + const count = entries.length; + if (count === 0) { + log.info(`${header}: none`); + return; + } + log.info(`${header} (${count})`); + // eslint-disable-next-line no-console + console.table( + entries.map(({ source, references }) => ({ + 'Not exported source': source, + references, + })) + ); + }; + for (const plugin of plugins) { - // Note that the filtering is done here (per-plugin), rather than earlier in the pipeline. - // This keeps the metrics task aligned with how other docs tasks process plugins and ensures - // that all plugin data has been collected before selectively reporting metrics. if (options.pluginFilter && !options.pluginFilter.includes(plugin.id)) { continue; } @@ -162,7 +197,7 @@ export function reportMetrics( // eslint-disable-next-line no-console console.table(referencedDeprecations[id]); } else { - log.info(`No referenced deprecations for plugin ${plugin.id}`); + log.info(`No referenced deprecations for ${plugin.id}`); } if (pluginStats.noReferences.length > 0) { // eslint-disable-next-line no-console @@ -173,7 +208,7 @@ export function reportMetrics( })) ); } else { - log.info(`No unused APIs for plugin ${plugin.id}`); + log.info(`No unused APIs for ${plugin.id}`); } } @@ -184,14 +219,12 @@ export function reportMetrics( pluginStats.deprecatedAPIsReferencedCount === 0 && (!missingApiItems[id] || Object.keys(missingApiItems[id]).length === 0); - log.info(`--- Plugin '${id}' ${passesAllChecks ? 'passes all checks ----' : '----'}`); + log.info(`--- '${id}' ${passesAllChecks ? 'passes all checks ----' : '----'}`); if (!passesAllChecks) { - log.info(`${pluginStats.isAnyType.length} API items with ANY`); - if (options.stats.includes('any')) { - // eslint-disable-next-line no-console - console.table( + printIssueTable( + 'API items with ANY', pluginStats.isAnyType.map((d) => ({ id: d.id, link: getLink(d), @@ -199,10 +232,9 @@ export function reportMetrics( ); } - log.info(`${pluginStats.missingComments.length} API items missing comments`); if (options.stats.includes('comments')) { - // eslint-disable-next-line no-console - console.table( + printIssueTable( + 'API items missing comments', pluginStats.missingComments.map((d) => ({ id: d.id, link: getLink(d), @@ -211,15 +243,12 @@ export function reportMetrics( } if (missingApiItems[id]) { - log.info(`${Object.keys(missingApiItems[id]).length} referenced API items not exported`); if (options.stats.includes('exports')) { - // eslint-disable-next-line no-console - console.table( - Object.keys(missingApiItems[id]).map((key) => ({ - 'Not exported source': key, - references: missingApiItems[id][key].join(', '), - })) - ); + const exportsTable = Object.keys(missingApiItems[id]).map((key) => ({ + source: key, + references: missingApiItems[id][key].join(', '), + })); + printMissingExportsTable('Referenced API items not exported', exportsTable); } } } diff --git a/packages/kbn-docs-utils/src/cli/tasks/setup_project.test.ts b/packages/kbn-docs-utils/src/cli/tasks/setup_project.test.ts index 4009496c575b9..42a555809fe2e 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/setup_project.test.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/setup_project.test.ts @@ -11,6 +11,7 @@ import Path from 'path'; import { ToolingLog } from '@kbn/tooling-log'; import { setupProject } from './setup_project'; import type { CliContext, CliOptions } from '../types'; +import type { FindPluginsOptions } from '../../find_plugins'; // Mock dependencies - order matters: mock get_all_doc_file_ids first to prevent globby from loading jest.mock('../../mdx/get_all_doc_file_ids', () => ({ @@ -112,6 +113,7 @@ describe('setupProject', () => { const result = await setupProject(context, options); expect(result.plugins).toBeDefined(); + expect(result.allPlugins).toBeDefined(); expect(result.pathsByPlugin).toBeDefined(); expect(result.project).toBeDefined(); }); @@ -134,9 +136,21 @@ describe('setupProject', () => { const Fs = jest.requireMock('fs'); Fs.existsSync.mockReturnValue(true); - (findPlugins as jest.Mock).mockReturnValue([ - { id: 'test-plugin', isPlugin: true, directory: '/tmp/test' }, - ]); + const mockPlugin = { + id: 'test-plugin', + directory: '/mock/repo/root/src/plugins/test', + isPlugin: true, + manifest: { id: 'test-plugin', owner: { name: 'test-team' }, serviceFolders: [] }, + manifestPath: '/mock/repo/root/src/plugins/test/kibana.json', + }; + + // Return all plugins for allPlugins, filtered for filteredPlugins + (findPlugins as jest.Mock).mockImplementation((options?: FindPluginsOptions) => { + if (options?.pluginFilter) { + return [mockPlugin]; + } + return [mockPlugin, { ...mockPlugin, id: 'other-plugin' }]; + }); const options: CliOptions = { collectReferences: false, @@ -173,4 +187,76 @@ describe('setupProject', () => { "expected --package '@kbn/nonexistent-package' was not found" ); }); + + it('scopes TypeScript project to single plugin directory when pluginFilter has one plugin', async () => { + const { Project } = jest.requireMock('ts-morph'); + const mockProject = Project(); + + jest.clearAllMocks(); + + const mockPlugin = { + id: 'single-plugin', + directory: '/mock/repo/root/src/plugins/single', + isPlugin: true, + manifest: { id: 'single-plugin', owner: { name: 'test-team' }, serviceFolders: [] }, + manifestPath: '/mock/repo/root/src/plugins/single/kibana.json', + }; + + // Return all plugins for allPlugins, filtered for plugins + (findPlugins as jest.Mock).mockImplementation((options?: FindPluginsOptions) => { + if (options?.pluginFilter) { + return [mockPlugin]; + } + return [mockPlugin, { ...mockPlugin, id: 'other-plugin' }]; + }); + + const options: CliOptions = { + collectReferences: false, + pluginFilter: ['single-plugin'], + }; + + await setupProject(context, options); + + // Should use the single plugin's tsconfig + expect(Project).toHaveBeenCalledWith({ + tsConfigFilePath: '/mock/repo/root/src/plugins/single/tsconfig.json', + skipAddingFilesFromTsConfig: true, + }); + + // Should only add files from the single plugin directory + expect(mockProject.addSourceFilesAtPaths).toHaveBeenCalledWith([ + '/mock/repo/root/src/plugins/single/**/*.ts', + '!**/*.d.ts', + ]); + + // Should NOT call resolveSourceFileDependencies for single-plugin builds + expect(mockProject.resolveSourceFileDependencies).not.toHaveBeenCalled(); + }); + + it('loads full codebase and resolves dependencies when no pluginFilter', async () => { + const { Project } = jest.requireMock('ts-morph'); + const mockProject = Project(); + + jest.clearAllMocks(); + + (findPlugins as jest.Mock).mockReturnValue([]); + + const options: CliOptions = { + collectReferences: false, + }; + + await setupProject(context, options); + + // Should use the repo root tsconfig + expect(Project).toHaveBeenCalledWith({ + tsConfigFilePath: '/mock/repo/root/tsconfig.json', + skipAddingFilesFromTsConfig: true, + }); + + // Should add files from all directories + expect(mockProject.addSourceFilesAtPaths).toHaveBeenCalledTimes(8); + + // Should call resolveSourceFileDependencies for full builds + expect(mockProject.resolveSourceFileDependencies).toHaveBeenCalled(); + }); }); diff --git a/packages/kbn-docs-utils/src/cli/tasks/setup_project.ts b/packages/kbn-docs-utils/src/cli/tasks/setup_project.ts index f8c828945a037..5c5368dc5e3bf 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/setup_project.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/setup_project.ts @@ -36,6 +36,7 @@ function getTsProject(repoPath: string, overridePath?: string): Project { }); if (!overridePath) { + // Full build: load all source files and resolve dependencies upfront project.addSourceFilesAtPaths([`${repoPath}/x-pack/plugins/**/*.ts`, '!**/*.d.ts']); project.addSourceFilesAtPaths([`${repoPath}/x-pack/packages/**/*.ts`, '!**/*.d.ts']); project.addSourceFilesAtPaths([`${repoPath}/x-pack/platform/**/*.ts`, '!**/*.d.ts']); @@ -44,10 +45,17 @@ function getTsProject(repoPath: string, overridePath?: string): Project { project.addSourceFilesAtPaths([`${repoPath}/src/platform/**/*.ts`, '!**/*.d.ts']); project.addSourceFilesAtPaths([`${repoPath}/src/core/packages/**/*.ts`, '!**/*.d.ts']); project.addSourceFilesAtPaths([`${repoPath}/packages/**/*.ts`, '!**/*.d.ts']); + project.resolveSourceFileDependencies(); } else { + // Single-plugin build: only load files from the target plugin directory. + // We intentionally skip resolveSourceFileDependencies() here because: + // 1. ts-morph resolves dependencies lazily when accessed (e.g., via getType()). + // 2. This significantly reduces memory usage and startup time for single-plugin builds. + // 3. Cross-package type references still resolve correctly via the tsconfig paths. + // Trade-off: First access to external types may be slightly slower, but overall + // build time is reduced since we don't load the entire codebase into memory. project.addSourceFilesAtPaths([`${overridePath}/**/*.ts`, '!**/*.d.ts']); } - project.resolveSourceFileDependencies(); return project; } @@ -84,11 +92,14 @@ export async function setupProject( spanInitialDocIds?.end(); const spanPlugins = transaction.startSpan('build_api_docs.findPlugins', 'setup'); - const plugins = hasAnyFilter ? findPlugins({ pluginFilter, packageFilter }) : findPlugins(); + // Always find all plugins for cross-reference resolution. + const allPlugins = findPlugins(); + // Find filtered plugins if a filter is provided. + const filteredPlugins = hasAnyFilter ? findPlugins({ pluginFilter, packageFilter }) : allPlugins; // Validate that all requested plugins were found. if (hasPluginFilter && pluginFilter) { - const foundPluginIds = plugins.filter((p) => p.isPlugin).map((p) => p.id); + const foundPluginIds = filteredPlugins.filter((p) => p.isPlugin).map((p) => p.id); const missingPlugins = pluginFilter.filter((id) => !foundPluginIds.includes(id)); if (missingPlugins.length > 0) { throw createFlagError(`expected --plugin '${missingPlugins.join(', ')}' was not found`); @@ -97,12 +108,15 @@ export async function setupProject( // Validate that all requested packages were found. if (hasPackageFilter && packageFilter) { - const foundPackageIds = plugins.filter((p) => !p.isPlugin).map((p) => p.id); + const foundPackageIds = filteredPlugins.filter((p) => !p.isPlugin).map((p) => p.id); const missingPackages = packageFilter.filter((id) => !foundPackageIds.includes(id)); if (missingPackages.length > 0) { throw createFlagError(`expected --package '${missingPackages.join(', ')}' was not found`); } } + + // Use filtered plugins for iteration, all plugins for reference resolution + const plugins = hasAnyFilter ? filteredPlugins : allPlugins; spanPlugins?.end(); const spanPathsByPackage = transaction.startSpan('build_api_docs.getPathsByPackage', 'setup'); @@ -112,7 +126,7 @@ export async function setupProject( const spanProject = transaction.startSpan('build_api_docs.getTsProject', 'setup'); // Optimize: when building a single plugin/package, scope the TS project to just that directory const singlePluginDirectory = - hasAnyFilter && plugins.length === 1 ? plugins[0].directory : undefined; + hasAnyFilter && filteredPlugins.length === 1 ? filteredPlugins[0].directory : undefined; const project = getTsProject(REPO_ROOT, singlePluginDirectory); spanProject?.end(); @@ -130,6 +144,7 @@ export async function setupProject( return { plugins, + allPlugins, pathsByPlugin, project, initialDocIds, diff --git a/packages/kbn-docs-utils/src/cli/tasks/write_docs.test.ts b/packages/kbn-docs-utils/src/cli/tasks/write_docs.test.ts index 67ada62f759ef..e11defe651cb6 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/write_docs.test.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/write_docs.test.ts @@ -69,6 +69,7 @@ describe('writeDocs', () => { pathsByPlugin: new Map(), project: {} as any, initialDocIds: ['doc1', 'doc2'], + allPlugins: [mockPlugin], }; apiMapResult = { @@ -155,6 +156,34 @@ describe('writeDocs', () => { expect(writePluginDocs).toHaveBeenCalled(); }); + it('skips aggregate docs when pluginFilter is provided', async () => { + const options: CliOptions = { + collectReferences: false, + pluginFilter: ['test-plugin'], + }; + + await writeDocs(context, setupResult, apiMapResult, allPluginStats, options); + + expect(writePluginDirectoryDoc).not.toHaveBeenCalled(); + expect(writeDeprecationDocByPlugin).not.toHaveBeenCalled(); + expect(writeDeprecationDueByTeam).not.toHaveBeenCalled(); + expect(writeDeprecationDocByApi).not.toHaveBeenCalled(); + }); + + it('skips aggregate docs when packageFilter is provided', async () => { + const options: CliOptions = { + collectReferences: false, + packageFilter: ['@kbn/test-package'], + }; + + await writeDocs(context, setupResult, apiMapResult, allPluginStats, options); + + expect(writePluginDirectoryDoc).not.toHaveBeenCalled(); + expect(writeDeprecationDocByPlugin).not.toHaveBeenCalled(); + expect(writeDeprecationDueByTeam).not.toHaveBeenCalled(); + expect(writeDeprecationDocByApi).not.toHaveBeenCalled(); + }); + it('trims deleted docs from nav when initialDocIds are provided', async () => { const options: CliOptions = { collectReferences: false, diff --git a/packages/kbn-docs-utils/src/cli/tasks/write_docs.ts b/packages/kbn-docs-utils/src/cli/tasks/write_docs.ts index 0a6e6388d7c7d..343092f5f7d85 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/write_docs.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/write_docs.ts @@ -45,24 +45,51 @@ export async function writeDocs( ): Promise { const { log, transaction, outputFolder } = context; const { initialDocIds } = setupResult; - const { plugins } = setupResult; + const { plugins, allPlugins } = setupResult; const { pluginApiMap, referencedDeprecations, unreferencedDeprecations } = apiMapResult; - if (!options.stats) { + // Only write aggregate docs (plugin directory, deprecation summaries) for full builds. + // Filtered builds skip these because: + // 1. Aggregate docs require complete data from all plugins to be accurate. + // 2. Writing partial deprecation data would be misleading (e.g., "deprecation by team"). + // 3. Single-plugin builds focus on validating that plugin's docs only. + const hasFilter = options.pluginFilter?.length || options.packageFilter?.length; + if (!options.stats && !hasFilter) { const spanWritePluginDirectoryDoc = transaction.startSpan( 'build_api_docs.writePluginDirectoryDoc', 'write' ); - await writePluginDirectoryDoc(outputFolder, pluginApiMap, allPluginStats, log); - spanWritePluginDirectoryDoc?.end(); + + const spanWriteDeprecationDocByPlugin = transaction.startSpan( + 'build_api_docs.writeDeprecationDocByPlugin', + 'write' + ); + await writeDeprecationDocByPlugin(outputFolder, referencedDeprecations, log); + spanWriteDeprecationDocByPlugin?.end(); + + const spanWriteDeprecationDueByTeam = transaction.startSpan( + 'build_api_docs.writeDeprecationDueByTeam', + 'write' + ); + await writeDeprecationDueByTeam(outputFolder, referencedDeprecations, allPlugins, log); + spanWriteDeprecationDueByTeam?.end(); + + const spanWriteDeprecationDocByApi = transaction.startSpan( + 'build_api_docs.writeDeprecationDocByApi', + 'write' + ); + await writeDeprecationDocByApi( + outputFolder, + referencedDeprecations, + unreferencedDeprecations, + log + ); + spanWriteDeprecationDocByApi?.end(); } for (const plugin of plugins) { - // Note that the filtering is done in this task, and not during plugin discovery, because the entire - // public plugin API has to be parsed in order to correctly determine reference links, and ensure that - // `removeBrokenLinks` doesn't remove more links than necessary. if (options.pluginFilter && !options.pluginFilter.includes(plugin.id)) { continue; } @@ -73,7 +100,7 @@ export async function writeDocs( if (!options.stats) { if (pluginStats.apiCount > 0) { - log.info(`Writing public API doc for plugin ${pluginApi.id}.`); + log.info(`Writing public API doc for ${pluginApi.id}.`); const spanWritePluginDocs = transaction.startSpan( 'build_api_docs.writePluginDocs', @@ -84,40 +111,8 @@ export async function writeDocs( spanWritePluginDocs?.end(); } else { - log.info(`Plugin ${pluginApi.id} has no public API.`); + log.info(`${pluginApi.id} has no public API.`); } - - const spanWriteDeprecationDocByPlugin = transaction.startSpan( - 'build_api_docs.writeDeprecationDocByPlugin', - 'write' - ); - - await writeDeprecationDocByPlugin(outputFolder, referencedDeprecations, log); - - spanWriteDeprecationDocByPlugin?.end(); - - const spanWriteDeprecationDueByTeam = transaction.startSpan( - 'build_api_docs.writeDeprecationDueByTeam', - 'write' - ); - - await writeDeprecationDueByTeam(outputFolder, referencedDeprecations, plugins, log); - - spanWriteDeprecationDueByTeam?.end(); - - const spanWriteDeprecationDocByApi = transaction.startSpan( - 'build_api_docs.writeDeprecationDocByApi', - 'write' - ); - - await writeDeprecationDocByApi( - outputFolder, - referencedDeprecations, - unreferencedDeprecations, - log - ); - - spanWriteDeprecationDocByApi?.end(); } } diff --git a/packages/kbn-docs-utils/src/cli/types.ts b/packages/kbn-docs-utils/src/cli/types.ts index b2e3fa26b8ae4..ea6bfb18cd878 100644 --- a/packages/kbn-docs-utils/src/cli/types.ts +++ b/packages/kbn-docs-utils/src/cli/types.ts @@ -72,8 +72,10 @@ export interface CliContext { * Result from setup_project task. */ export interface SetupProjectResult { - /** Discovered plugins and packages. */ + /** Plugins/packages to analyze (may be filtered via --plugin/--package). */ plugins: PluginOrPackage[]; + /** All discovered plugins/packages (for cross-reference resolution). */ + allPlugins: PluginOrPackage[]; /** File paths grouped by package. */ pathsByPlugin: Map; /** TypeScript project instance. */ diff --git a/packages/kbn-docs-utils/src/count_eslint_disable.test.ts b/packages/kbn-docs-utils/src/count_eslint_disable.test.ts index 6bd081a13f049..1a31c1f3eb288 100644 --- a/packages/kbn-docs-utils/src/count_eslint_disable.test.ts +++ b/packages/kbn-docs-utils/src/count_eslint_disable.test.ts @@ -34,7 +34,7 @@ describe('countEslintDisableLines', () => { expect(counts).toMatchInlineSnapshot(` Object { "eslintDisableFileCount": 3, - "eslintDisableLineCount": 9, + "eslintDisableLineCount": 8, } `); }); diff --git a/packages/kbn-docs-utils/src/get_plugin_api_map.test.ts b/packages/kbn-docs-utils/src/get_plugin_api_map.test.ts index c2e037944ddf1..f22f83522a0df 100644 --- a/packages/kbn-docs-utils/src/get_plugin_api_map.test.ts +++ b/packages/kbn-docs-utils/src/get_plugin_api_map.test.ts @@ -39,7 +39,7 @@ describe('getPluginApiMap', () => { ); const plugins = [pluginA, pluginB]; - const result = getPluginApiMap(project, plugins, log, { + const result = getPluginApiMap(project, plugins, plugins, log, { collectReferences: false, }); @@ -54,7 +54,7 @@ describe('getPluginApiMap', () => { const pluginA = getKibanaPlatformPlugin('pluginA'); const plugins = [pluginA]; - const result = getPluginApiMap(project, plugins, log, { + const result = getPluginApiMap(project, plugins, plugins, log, { collectReferences: false, }); @@ -67,7 +67,7 @@ describe('getPluginApiMap', () => { const pluginA = getKibanaPlatformPlugin('pluginA'); const plugins = [pluginA]; - const result = getPluginApiMap(project, plugins, log, { + const result = getPluginApiMap(project, plugins, plugins, log, { collectReferences: true, }); @@ -79,7 +79,7 @@ describe('getPluginApiMap', () => { const pluginA = getKibanaPlatformPlugin('pluginA'); const plugins = [pluginA]; - const result = getPluginApiMap(project, plugins, log, { + const result = getPluginApiMap(project, plugins, plugins, log, { collectReferences: false, }); @@ -96,7 +96,7 @@ describe('getPluginApiMap', () => { ); const plugins = [pluginA, pluginB]; - const result = getPluginApiMap(project, plugins, log, { + const result = getPluginApiMap(project, plugins, plugins, log, { collectReferences: false, pluginFilter: ['pluginA'], }); @@ -111,7 +111,7 @@ describe('getPluginApiMap', () => { const pluginA = getKibanaPlatformPlugin('pluginA'); const plugins = [pluginA]; - const result = getPluginApiMap(project, plugins, log, { + const result = getPluginApiMap(project, plugins, plugins, log, { collectReferences: true, }); @@ -124,7 +124,7 @@ describe('getPluginApiMap', () => { }); it('handles empty plugin list', () => { - const result = getPluginApiMap(project, [], log, { + const result = getPluginApiMap(project, [], [], log, { collectReferences: false, }); diff --git a/packages/kbn-docs-utils/src/get_plugin_api_map.ts b/packages/kbn-docs-utils/src/get_plugin_api_map.ts index 9cd53f1d0f960..4e6a4a7870591 100644 --- a/packages/kbn-docs-utils/src/get_plugin_api_map.ts +++ b/packages/kbn-docs-utils/src/get_plugin_api_map.ts @@ -26,6 +26,7 @@ import type { AdoptionTrackedAPIStats } from './types'; export function getPluginApiMap( project: Project, plugins: PluginOrPackage[], + allPlugins: PluginOrPackage[], log: ToolingLog, { collectReferences, pluginFilter }: { collectReferences: boolean; pluginFilter?: string[] } ): { @@ -43,7 +44,15 @@ export function getPluginApiMap( plugins.forEach((plugin) => { const captureReferences = collectReferences && (!pluginFilter || pluginFilter.indexOf(plugin.id) >= 0); - const { pluginApi, warnings } = getPluginApi(project, plugin, plugins, log, captureReferences); + + // Pass allPlugins for cross-reference resolution (links to other packages) + const { pluginApi, warnings } = getPluginApi( + project, + plugin, + allPlugins, + log, + captureReferences + ); pluginApiMap[plugin.id] = pluginApi; if (warnings.unnamedExports.length > 0) { unnamedExports[plugin.id] = warnings.unnamedExports; diff --git a/packages/kbn-docs-utils/src/integration_tests/api_doc_suite.test.ts b/packages/kbn-docs-utils/src/integration_tests/api_doc_suite.test.ts index 31002079f1d2a..6ca3e29dad00b 100644 --- a/packages/kbn-docs-utils/src/integration_tests/api_doc_suite.test.ts +++ b/packages/kbn-docs-utils/src/integration_tests/api_doc_suite.test.ts @@ -125,7 +125,7 @@ beforeAll(async () => { referencedDeprecations, adoptionTrackedAPIs, unnamedExports, - } = getPluginApiMap(project, plugins, log, { collectReferences: false }); + } = getPluginApiMap(project, plugins, plugins, log, { collectReferences: false }); doc = pluginApiMap.pluginA; diff --git a/packages/kbn-docs-utils/src/utils.ts b/packages/kbn-docs-utils/src/utils.ts index c80c6371c0c6d..c50cc2ed2e3dc 100644 --- a/packages/kbn-docs-utils/src/utils.ts +++ b/packages/kbn-docs-utils/src/utils.ts @@ -194,9 +194,9 @@ export function removeBrokenLinks( const uniqueMissing = Object.keys(missingApiItems[pluginApi.id] ?? {}).length; - if (uniqueMissing > 0) { + if (missingCnt > 0) { log.info( - `${pluginApi.id} had ${uniqueMissing} missing exported API item(s). Removed ${missingCnt} reference(s) to avoid broken links. Use '--stats exports' to list missing exports.` + `${pluginApi.id}: removed ${missingCnt} broken link(s) referencing ${uniqueMissing} unexported API item(s). Run 'check_package_docs --check exports' for details.` ); } } @@ -211,7 +211,16 @@ function removeBrokenLinksFromApi( if (api.signature) { api.signature = api.signature.map((sig) => { if (typeof sig !== 'string') { - if (!apiItemExists(sig.text, sig.scope, pluginApiMap[sig.pluginId])) { + const referencedPluginApi = pluginApiMap[sig.pluginId]; + + // If the referenced plugin isn't in our plugin map (e.g., single-package build), + // keep the link as-is since we can't verify if it exists. + if (!referencedPluginApi) { + return sig; + } + + // Plugin is in the map - check if the specific API item exists. + if (!apiItemExists(sig.text, sig.scope, referencedPluginApi)) { if (missingApiItems[sig.pluginId] === undefined) { missingApiItems[sig.pluginId] = {}; } @@ -223,6 +232,7 @@ function removeBrokenLinksFromApi( missingApiItems[sig.pluginId][sourceId].push(`${pluginId}-${api.id}`); missingCnt++; + // Return plain text for broken links. return sig.text; } return sig;