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..491c34d15eacc 100644 --- a/packages/kbn-docs-utils/src/build_api_docs_cli.ts +++ b/packages/kbn-docs-utils/src/build_api_docs_cli.ts @@ -44,6 +44,9 @@ async function endTransactionWithFailure(transaction: Transaction | null) { } } +/** + * Runs the build API docs CLI, generating API documentation for Kibana plugins and packages. + */ export function runBuildApiDocsCli() { startApm(); run( @@ -85,6 +88,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..13a579c200d89 100644 --- a/packages/kbn-docs-utils/src/check_package_docs_cli.ts +++ b/packages/kbn-docs-utils/src/check_package_docs_cli.ts @@ -27,6 +27,7 @@ import { } from './cli'; import type { PluginOrPackage, MissingApiItemMap } from './types'; import type { AllPluginStats } from './cli/types'; +import { writeFlatStatsFiles } from './cli/tasks/flat_stats'; type ValidationCheck = 'any' | 'comments' | 'exports' | 'unnamed'; @@ -129,6 +130,7 @@ export const runCheckPackageDocs = async (log: ToolingLog, flags: CliFlags) => { const apiMapResult = buildApiMap( setupResult.project, setupResult.plugins, + setupResult.allPlugins, log, transaction, optionsWithChecks @@ -142,6 +144,10 @@ export const runCheckPackageDocs = async (log: ToolingLog, flags: CliFlags) => { optionsWithChecks ); + if (optionsWithChecks.writeStats) { + writeFlatStatsFiles(setupResult.plugins, apiMapResult, allPluginStats); + } + reportMetrics(setupResult, apiMapResult, allPluginStats, log, transaction, { ...optionsWithChecks, stats: checks, @@ -160,13 +166,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'); @@ -177,6 +183,9 @@ export const runCheckPackageDocs = async (log: ToolingLog, flags: CliFlags) => { } }; +/** + * Runs the check package docs CLI, validating API documentation for Kibana plugins and packages. + */ export const runCheckPackageDocsCli = () => { run( async ({ log, flags }) => { @@ -188,11 +197,13 @@ export const runCheckPackageDocsCli = () => { }, flags: { string: ['plugin', 'package', 'check'], + boolean: ['write'], help: ` --plugin Optionally, run for only a specific plugin by its plugin ID (plugin.id in kibana.jsonc). --package Optionally, run for only a specific package by its package ID (id in kibana.jsonc, e.g., @kbn/core). --check Optional. Specify validation checks: any, comments, exports, or all (default). Can be provided multiple times to combine checks. + --write Write stats to a flat JSON file in each plugin's target/api_docs/ directory. `, }, } diff --git a/packages/kbn-docs-utils/src/cli/parse_cli_flags.ts b/packages/kbn-docs-utils/src/cli/parse_cli_flags.ts index 580a7671b9f8a..3bf8005893296 100644 --- a/packages/kbn-docs-utils/src/cli/parse_cli_flags.ts +++ b/packages/kbn-docs-utils/src/cli/parse_cli_flags.ts @@ -108,6 +108,7 @@ const normalizeStats = (value: unknown | string[]) => { */ export function parseCliFlags(flags: CliFlags): CliOptions { const collectReferences = flags.references === true; + const writeStats = flags.write === true; const pluginFilter = dedupe(normalizeStringList(flags.plugin, 'plugin')); const packageFilter = dedupe(normalizeStringList(flags.package, 'package')); const rawStats = normalizeStats(flags.stats); @@ -122,5 +123,6 @@ export function parseCliFlags(flags: CliFlags): CliOptions { stats, pluginFilter, packageFilter, + writeStats, }; } 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/flat_stats.ts b/packages/kbn-docs-utils/src/cli/tasks/flat_stats.ts new file mode 100644 index 0000000000000..2b64f181c765d --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/flat_stats.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import fs from 'fs'; +import Path from 'path'; + +import type { ApiDeclaration, MissingApiItemMap, PluginOrPackage } from '../../types'; +import type { AllPluginStats, BuildApiMapResult } from '../types'; +import { getLink } from './get_link'; + +/** Shape of a single stat entry in the flat JSON output. */ +export interface FlatStatEntry { + id: string; + label: string; + path: string; + type: string; + lineNumber?: number; + columnNumber?: number; + link: string; +} + +/** Shape of a missing-export entry in the flat JSON output. */ +export interface FlatMissingExportEntry { + source: string; + references: string[]; +} + +/** Complete flat stats JSON written per plugin/package. */ +export interface FlatStats { + counts: { + apiCount: number; + missingExports: number; + missingComments: number; + isAnyType: number; + noReferences: number; + missingReturns: number; + paramDocMismatches: number; + missingComplexTypeInfo: number; + }; + missingComments: FlatStatEntry[]; + isAnyType: FlatStatEntry[]; + noReferences: FlatStatEntry[]; + missingReturns: FlatStatEntry[]; + paramDocMismatches: FlatStatEntry[]; + missingComplexTypeInfo: FlatStatEntry[]; + missingExports: FlatMissingExportEntry[]; +} + +const mapStat = (dec: ApiDeclaration): FlatStatEntry => ({ + id: dec.id, + label: dec.label, + path: dec.path, + type: dec.type, + lineNumber: dec.lineNumber, + columnNumber: dec.columnNumber, + link: getLink(dec), +}); + +export const buildFlatStatsForPlugin = ( + pluginId: string, + pluginStats: AllPluginStats[string], + missingApiItems: MissingApiItemMap +): FlatStats => { + const missingExportsCount = missingApiItems[pluginId] + ? Object.keys(missingApiItems[pluginId]).length + : 0; + const missingExportsList = missingApiItems[pluginId] + ? Object.keys(missingApiItems[pluginId]).map((source) => ({ + source, + references: missingApiItems[pluginId][source], + })) + : []; + + return { + counts: { + apiCount: pluginStats.apiCount, + missingExports: missingExportsCount, + missingComments: pluginStats.missingComments.length, + isAnyType: pluginStats.isAnyType.length, + noReferences: pluginStats.noReferences.length, + missingReturns: pluginStats.missingReturns.length, + paramDocMismatches: pluginStats.paramDocMismatches.length, + missingComplexTypeInfo: pluginStats.missingComplexTypeInfo.length, + }, + missingComments: pluginStats.missingComments.map(mapStat), + isAnyType: pluginStats.isAnyType.map(mapStat), + noReferences: pluginStats.noReferences.map(mapStat), + missingReturns: pluginStats.missingReturns.map(mapStat), + paramDocMismatches: pluginStats.paramDocMismatches.map(mapStat), + missingComplexTypeInfo: pluginStats.missingComplexTypeInfo.map(mapStat), + missingExports: missingExportsList, + }; +}; + +export const writeFlatStatsFiles = ( + plugins: PluginOrPackage[], + apiMapResult: BuildApiMapResult, + allPluginStats: AllPluginStats +) => { + for (const plugin of plugins) { + const stats = allPluginStats[plugin.id]; + if (!stats) { + continue; + } + const flat = buildFlatStatsForPlugin(plugin.id, stats, apiMapResult.missingApiItems); + const pluginTargetDir = Path.resolve(plugin.directory, 'target', 'api_docs'); + fs.mkdirSync(pluginTargetDir, { recursive: true }); + const target = Path.join(pluginTargetDir, 'stats.json'); + fs.writeFileSync(target, JSON.stringify(flat, null, 2)); + } +}; diff --git a/packages/kbn-docs-utils/src/cli/tasks/get_link.ts b/packages/kbn-docs-utils/src/cli/tasks/get_link.ts new file mode 100644 index 0000000000000..a881d95f78250 --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/get_link.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { ApiDeclaration } from '../../types'; + +/** + * Generates a link to the GitHub source for an API declaration. + * + * When a `lineNumber` is available, produces a direct `#L42`-style anchor. + * Otherwise falls back to a text-fragment search (`#:~:text=...`). + * + * 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. + */ +export const 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)}`; +}; 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..76a507a651739 100644 --- a/packages/kbn-docs-utils/src/cli/tasks/report_metrics.ts +++ b/packages/kbn-docs-utils/src/cli/tasks/report_metrics.ts @@ -10,20 +10,8 @@ import type { Transaction } from 'elastic-apm-node'; import type { ToolingLog } from '@kbn/tooling-log'; import { CiStatsReporter } from '@kbn/ci-stats-reporter'; -import type { ApiDeclaration } from '../../types'; import type { AllPluginStats, BuildApiMapResult, CliOptions, SetupProjectResult } from '../types'; - -/** - * Generates a link to the GitHub source for an API declaration. - * - * @param declaration - API declaration to generate link for. - * @returns GitHub link to the source code. - */ -function getLink(declaration: ApiDeclaration): string { - return `https://github.com/elastic/kibana/tree/main/${ - declaration.path - }#:~:text=${encodeURIComponent(declaration.label)}`; -} +import { getLink } from './get_link'; /** * Reports metrics to CI stats and logs validation results. @@ -53,10 +41,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 +178,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 +189,7 @@ export function reportMetrics( })) ); } else { - log.info(`No unused APIs for plugin ${plugin.id}`); + log.info(`No unused APIs for ${plugin.id}`); } } @@ -184,14 +200,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 +213,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 +224,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..8f43d1bdba302 100644 --- a/packages/kbn-docs-utils/src/cli/types.ts +++ b/packages/kbn-docs-utils/src/cli/types.ts @@ -38,6 +38,8 @@ export interface CliFlags { plugin?: string | string[]; /** Package filter: single package ID or array of package IDs (id from kibana.jsonc). */ package?: string | string[]; + /** Whether to write stats to a flat JSON file. */ + write?: boolean; } /** @@ -52,6 +54,8 @@ export interface CliOptions { pluginFilter?: string[]; /** Package filter IDs (id from kibana.jsonc, e.g., @kbn/package-name). */ packageFilter?: string[]; + /** Whether to write stats to a flat JSON file. */ + writeStats?: boolean; } /** @@ -72,8 +76,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/find_plugins.ts b/packages/kbn-docs-utils/src/find_plugins.ts index b206e7800370c..d8e522f6d56c6 100644 --- a/packages/kbn-docs-utils/src/find_plugins.ts +++ b/packages/kbn-docs-utils/src/find_plugins.ts @@ -124,6 +124,12 @@ export function findPlugins(options?: FindPluginsOptions): PluginOrPackage[] { return result; } +/** + * Finds all plugins owned by a specific team. + * + * @param team - The GitHub team identifier (e.g., `@elastic/kibana-core`). + * @returns Array of plugins owned by the specified team. + */ export function findTeamPlugins(team: string): PluginOrPackage[] { const packages = getPackages(REPO_ROOT); const plugins = packages.filter( 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; diff --git a/src/platform/packages/shared/kbn-mcp-dev-server/README.md b/src/platform/packages/shared/kbn-mcp-dev-server/README.md index 76a90376aa287..1f3a9710ec3e5 100644 --- a/src/platform/packages/shared/kbn-mcp-dev-server/README.md +++ b/src/platform/packages/shared/kbn-mcp-dev-server/README.md @@ -145,6 +145,99 @@ By following these steps, you can successfully add and register a new tool to th The following tools are available in the MCP Dev Server. +## Documentation Validation Tools + +### `check_package_docs` + +Quickly check a Kibana plugin or package for documentation issues. Returns pass/fail status and issue counts. Use this for initial assessment before deciding to fix issues. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `target` | string | Yes | The plugin ID (e.g., `dashboard`), manifest ID (e.g., `@kbn/dashboard-plugin`), or file path to check. | +| `type` | `"plugin"` \| `"package"` \| `"file"` | No | How to interpret `target`. If omitted, auto-detected: scoped names (`@kbn/...`) and bare words are treated as IDs; paths containing `/` are treated as files. | + +**Example response:** + +```json +{ + "package": "@kbn/some-plugin", + "directory": "/path/to/plugin", + "passed": false, + "totalIssues": 7, + "actionable": 5, + "pending": 2, + "counts": { + "apiCount": 20, + "missingComments": 3, + "missingReturns": 1, + "paramDocMismatches": 1, + "missingComplexTypeInfo": 0, + "isAnyType": 0, + "missingExports": 2 + } +} +``` + +> **Note:** `passed` is based on `actionable` issues only. `pending` issues (like `missingExports`) require human input or changes in other packages. + +### `fix_package_docs` + +Get detailed documentation issues for a Kibana plugin, package, or file. Returns issues grouped by file with source context and fix templates. Use this after `check_package_docs` identifies problems. + +**Parameters:** + +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `target` | string | Yes | The plugin ID (e.g., `dashboard`), manifest ID (e.g., `@kbn/dashboard-plugin`), or file path to get issues for. | +| `type` | `"plugin"` \| `"package"` \| `"file"` | No | How to interpret `target`. If omitted, auto-detected (same rules as `check_package_docs`). | +| `issueTypes` | array | No | Filter to specific issue types (see below). | + +**Issue Types:** + +| Type | Default | Description | +|------|---------|-------------| +| `missingComments` | ✅ | APIs without JSDoc comments. | +| `missingReturns` | ✅ | Functions missing `@returns` tags. | +| `paramDocMismatches` | ✅ | Parameter documentation mismatches. | +| `missingComplexTypeInfo` | ✅ | Complex types lacking documentation. | +| `isAnyType` | ✅ | APIs using `any` type. | +| `missingExports` | ❌ | Types referenced but not exported. Opt-in only; often requires changes in consuming packages. | + +**Example response:** + +```json +{ + "package": "@kbn/some-plugin", + "totalIssues": 2, + "issuesByFile": [ + { + "file": "src/index.ts", + "issues": [ + { + "issueType": "missingReturns", + "id": "def-public.myFunction", + "label": "myFunction", + "file": "src/index.ts", + "line": 42, + "link": "https://github.com/elastic/kibana/blob/main/src/index.ts#L42", + "type": "Function", + "sourceSnippet": "39| /**\n40| * Does something.\n41| */\n42| export const myFunction = () => {\n43| return 'hello';\n44| };", + "template": "@returns {TYPE}" + } + ] + } + ] +} +``` + +**Workflow:** + +1. Run `check_package_docs` to get a quick summary. +2. If issues exist, run `fix_package_docs` to get detailed context. +3. Use the `sourceSnippet` and `template` to add missing documentation. + # Semantic Code Search For semantic code search, please use the [semantic-code-search-mcp-server](https://github.com/elastic/semantic-code-search-mcp-server). This server provides a suite of tools for exploring and understanding the Kibana codebase. diff --git a/src/platform/packages/shared/kbn-mcp-dev-server/src/server/index.ts b/src/platform/packages/shared/kbn-mcp-dev-server/src/server/index.ts index a239c89f01836..e9c3b4e1ea534 100644 --- a/src/platform/packages/shared/kbn-mcp-dev-server/src/server/index.ts +++ b/src/platform/packages/shared/kbn-mcp-dev-server/src/server/index.ts @@ -19,6 +19,8 @@ import { runUnitTestsTool } from '../tools/run_unit_tests'; import { runCiChecksTool } from '../tools/run_ci_checks'; import { searchByCodeownerTool } from '../tools/search_by_codeowner'; import { findDependencyReferencesTool } from '../tools/find_dependency_references'; +import { checkPackageDocsTool } from '../tools/check_package_docs'; +import { fixPackageDocsTool } from '../tools/fix_package_docs'; run(async () => { const server = new McpServer({ name: 'mcp-dev-server', version: '1.0.0' }); @@ -30,6 +32,8 @@ run(async () => { addTool(server, runCiChecksTool); addTool(server, searchByCodeownerTool); addTool(server, findDependencyReferencesTool); + addTool(server, checkPackageDocsTool); + addTool(server, fixPackageDocsTool); const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/check_package_docs.test.ts b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/check_package_docs.test.ts new file mode 100644 index 0000000000000..7aad702d7f537 --- /dev/null +++ b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/check_package_docs.test.ts @@ -0,0 +1,307 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import fs from 'fs'; + +import { checkPackageDocsTool } from './check_package_docs'; +import { parseToolResultJsonContent } from './test_utils'; + +jest.mock('@kbn/repo-packages', () => ({ + getPackages: jest.fn(() => [ + { + id: '@kbn/test-pkg', + directory: '/repo/packages/test-pkg', + manifest: { id: '@kbn/test-pkg' }, + isPlugin: (): boolean => false, + }, + { + id: '@kbn/dashboard-plugin', + directory: '/repo/src/platform/plugins/shared/dashboard', + manifest: { id: '@kbn/dashboard-plugin', plugin: { id: 'dashboard' } }, + isPlugin: (): boolean => true, + }, + { + id: '@kbn/dashboard-markdown', + directory: '/repo/src/platform/plugins/shared/dashboard_markdown', + manifest: { id: '@kbn/dashboard-markdown', plugin: { id: 'dashboardMarkdown' } }, + isPlugin: (): boolean => true, + }, + ]), +})); + +jest.mock('@kbn/repo-info', () => ({ + REPO_ROOT: '/repo', +})); + +jest.mock('execa', () => jest.fn().mockResolvedValue({ exitCode: 0 })); + +jest.mock('fs', () => ({ + existsSync: jest.fn(), + readFileSync: jest.fn(), +})); + +const mockFs = fs as jest.Mocked; + +const createMockStats = (overrides = {}) => ({ + counts: { + apiCount: 5, + missingExports: 0, + missingComments: 0, + isAnyType: 0, + noReferences: 0, + missingReturns: 0, + paramDocMismatches: 0, + missingComplexTypeInfo: 0, + }, + missingComments: [], + isAnyType: [], + missingReturns: [], + paramDocMismatches: [], + missingComplexTypeInfo: [], + ...overrides, +}); + +describe('checkPackageDocsTool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('target parameter', () => { + it('returns error when package is not found', async () => { + const result = await checkPackageDocsTool.handler({ target: '@kbn/unknown' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toContain("'@kbn/unknown' not found"); + }); + + it('returns error with suggestions when package is not found but similar ones exist', async () => { + // Use a partial match that doesn't exactly match any plugin ID. + const result = await checkPackageDocsTool.handler({ target: 'dashb' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toContain("'dashb' not found"); + expect(parsed.error).toContain('Did you mean'); + expect(parsed.error).toContain('dashboard (@kbn/dashboard-plugin)'); + }); + + it('finds plugin by plugin ID (e.g., dashboard)', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + // Using the plugin ID directly should work. + const result = await checkPackageDocsTool.handler({ target: 'dashboard' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toBeUndefined(); + expect(parsed.package).toBe('@kbn/dashboard-plugin'); + expect(parsed.passed).toBe(true); + }); + + it('finds package by manifest ID (auto-detects scoped package names)', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + // Scoped package names starting with @ should be auto-detected as IDs, not files. + const result = await checkPackageDocsTool.handler({ target: '@kbn/test-pkg' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toBeUndefined(); + expect(parsed.package).toBe('@kbn/test-pkg'); + }); + }); + + describe('type parameter', () => { + it('treats target as file when type is "file"', async () => { + const result = await checkPackageDocsTool.handler({ + target: '/some/random/file.ts', + type: 'file', + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toContain('Could not find a package'); + }); + + it('auto-detects file type when target contains "/"', async () => { + const result = await checkPackageDocsTool.handler({ + target: '/some/random/file.ts', + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toContain('Could not find a package'); + }); + + it('treats target as plugin/package when type is "plugin"', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await checkPackageDocsTool.handler({ + target: 'dashboard', + type: 'plugin', + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toBeUndefined(); + expect(parsed.package).toBe('@kbn/dashboard-plugin'); + }); + + it('treats target as package when type is "package"', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await checkPackageDocsTool.handler({ + target: '@kbn/test-pkg', + type: 'package', + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toBeUndefined(); + expect(parsed.package).toBe('@kbn/test-pkg'); + }); + }); + + describe('stats file handling', () => { + it('returns error when stats file does not exist after CLI run', async () => { + mockFs.existsSync.mockReturnValue(false); + + const result = await checkPackageDocsTool.handler({ target: '@kbn/test-pkg' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toContain('Stats file not found'); + }); + }); + + describe('pass/fail status', () => { + it('returns pass/fail status with counts for a clean package', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await checkPackageDocsTool.handler({ target: '@kbn/test-pkg' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.passed).toBe(true); + expect(parsed.totalIssues).toBe(0); + expect(parsed.counts.apiCount).toBe(5); + }); + + it('returns pass=false when issues exist', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify( + createMockStats({ + counts: { + apiCount: 10, + missingExports: 1, + missingComments: 3, + isAnyType: 2, + noReferences: 0, + missingReturns: 1, + paramDocMismatches: 0, + missingComplexTypeInfo: 0, + }, + missingComments: [{ path: 'src/a.ts' }, { path: 'src/b.ts' }, { path: 'src/c.ts' }], + isAnyType: [{ path: 'src/a.ts' }, { path: 'src/b.ts' }], + missingReturns: [{ path: 'src/a.ts' }], + }) + ) + ); + + const result = await checkPackageDocsTool.handler({ target: '@kbn/test-pkg' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.passed).toBe(false); + expect(parsed.totalIssues).toBe(7); // 3 + 2 + 1 + 1 (pending) + expect(parsed.actionable).toBe(6); // 3 + 2 + 1 + expect(parsed.pending).toBe(1); + expect(parsed.counts.missingComments).toBe(3); + expect(parsed.counts.isAnyType).toBe(2); + expect(parsed.counts.missingReturns).toBe(1); + expect(parsed.counts.missingExports).toBe(1); + }); + + it('returns passed=true when only pending issues exist', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify( + createMockStats({ + counts: { + apiCount: 5, + missingExports: 3, + missingComments: 0, + isAnyType: 0, + noReferences: 0, + missingReturns: 0, + paramDocMismatches: 0, + missingComplexTypeInfo: 0, + }, + }) + ) + ); + + const result = await checkPackageDocsTool.handler({ target: '@kbn/test-pkg' }); + const parsed = parseToolResultJsonContent(result); + + // Package passes because missingExports are pending (need human input). + expect(parsed.passed).toBe(true); + expect(parsed.totalIssues).toBe(3); // includes pending + expect(parsed.actionable).toBe(0); + expect(parsed.pending).toBe(3); + }); + }); + + describe('file filtering', () => { + it('filters issues to specific file when target is a file path', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify( + createMockStats({ + counts: { + apiCount: 10, + missingExports: 0, + missingComments: 3, + isAnyType: 1, + noReferences: 0, + missingReturns: 1, + paramDocMismatches: 0, + missingComplexTypeInfo: 0, + }, + missingComments: [ + { path: 'packages/test-pkg/src/index.ts' }, + { path: 'packages/test-pkg/src/other.ts' }, + { path: 'packages/test-pkg/src/index.ts' }, + ], + isAnyType: [{ path: 'packages/test-pkg/src/index.ts' }], + missingReturns: [{ path: 'packages/test-pkg/src/other.ts' }], + }) + ) + ); + + const result = await checkPackageDocsTool.handler({ + target: 'packages/test-pkg/src/index.ts', + type: 'file', + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toBeUndefined(); + expect(parsed.file).toBe('packages/test-pkg/src/index.ts'); + // Should only count issues from index.ts (2 missingComments + 1 isAnyType). + expect(parsed.totalIssues).toBe(3); + expect(parsed.counts.missingComments).toBe(2); + expect(parsed.counts.isAnyType).toBe(1); + expect(parsed.counts.missingReturns).toBe(0); + }); + }); + + describe('tool metadata', () => { + it('has correct tool metadata', () => { + expect(checkPackageDocsTool.name).toBe('check_package_docs'); + expect(checkPackageDocsTool.description).toContain('Check'); + expect(checkPackageDocsTool.description).toContain('documentation issues'); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/check_package_docs.ts b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/check_package_docs.ts new file mode 100644 index 0000000000000..7b8b72e68b1e9 --- /dev/null +++ b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/check_package_docs.ts @@ -0,0 +1,144 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import fs from 'fs'; +import Path from 'path'; + +import { z } from '@kbn/zod'; +import { REPO_ROOT } from '@kbn/repo-info'; + +import type { ToolDefinition } from '../types'; +import { type FlatStats, generateStats, resolveTarget } from './docs_utils'; + +const checkPackageDocsInputSchema = z.object({ + target: z + .string() + .describe( + 'The plugin ID (e.g., "dashboard"), manifest ID (e.g., "@kbn/dashboard-plugin"), or file path to check.' + ), + type: z + .enum(['plugin', 'package', 'file']) + .optional() + .describe( + 'How to interpret the target. If omitted, auto-detected: paths containing "/" are treated as files; otherwise tries plugin ID first, then manifest ID.' + ), +}); + +const checkPackageDocs = async (input: z.infer) => { + const resolved = resolveTarget(input.target, input.type); + if ('error' in resolved) { + return { error: resolved.error }; + } + + const { pkgInfo, filePath } = resolved; + const { pkg, cliFlag, cliId } = pkgInfo; + + const genResult = await generateStats(cliFlag, cliId); + if (!genResult.success) { + return { error: `Failed to generate stats: ${genResult.error}` }; + } + + const statsPath = Path.resolve(pkg.directory, 'target', 'api_docs', 'stats.json'); + + if (!fs.existsSync(statsPath)) { + return { + error: `Stats file not found at ${statsPath}. This may indicate an issue with the package.`, + }; + } + + let stats: FlatStats; + try { + stats = JSON.parse(fs.readFileSync(statsPath, 'utf-8')); + } catch { + return { error: `Failed to parse stats file at ${statsPath}.` }; + } + + if (filePath) { + const normalizedPath = filePath.startsWith('/') ? filePath : Path.resolve(REPO_ROOT, filePath); + const relativePath = Path.relative(REPO_ROOT, normalizedPath); + + const filterByFile = (arr: T[]) => + arr.filter((item) => item.path === relativePath || item.path === filePath); + + const fileCounts = { + missingComments: filterByFile(stats.missingComments).length, + missingReturns: filterByFile(stats.missingReturns).length, + paramDocMismatches: filterByFile(stats.paramDocMismatches).length, + missingComplexTypeInfo: filterByFile(stats.missingComplexTypeInfo).length, + isAnyType: filterByFile(stats.isAnyType).length, + }; + + const totalIssues = Object.values(fileCounts).reduce((sum, count) => sum + count, 0); + const filePassed = totalIssues === 0; + + return { + package: pkg.id, + file: filePath, + passed: filePassed, + totalIssues, + counts: fileCounts, + ...(filePassed + ? {} + : { + hint: `Use the fix_package_docs tool with target "${filePath}" to get detailed issues with source context and fix templates.`, + }), + }; + } + + const actionable = + stats.counts.missingComments + + stats.counts.missingReturns + + stats.counts.paramDocMismatches + + stats.counts.missingComplexTypeInfo + + stats.counts.isAnyType; + + const pending = stats.counts.missingExports; + const passed = actionable === 0; + + return { + package: pkg.id, + directory: pkg.directory, + passed, + totalIssues: actionable + pending, + actionable, + pending, + counts: { + apiCount: stats.counts.apiCount, + missingComments: stats.counts.missingComments, + missingReturns: stats.counts.missingReturns, + paramDocMismatches: stats.counts.paramDocMismatches, + missingComplexTypeInfo: stats.counts.missingComplexTypeInfo, + isAnyType: stats.counts.isAnyType, + missingExports: stats.counts.missingExports, + }, + ...(passed + ? {} + : { + hint: `Use the fix_package_docs tool with target "${input.target}" to get detailed issues with source context and fix templates.`, + }), + }; +}; + +export const checkPackageDocsTool: ToolDefinition = { + name: 'check_package_docs', + description: + 'Check a Kibana plugin or package for documentation issues. Returns pass/fail status and issue counts. Use this for quick validation before deciding to fix issues.', + inputSchema: checkPackageDocsInputSchema, + handler: async (input) => { + const result = await checkPackageDocs(input); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, +}; diff --git a/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/docs_utils.ts b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/docs_utils.ts new file mode 100644 index 0000000000000..49d88662c7973 --- /dev/null +++ b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/docs_utils.ts @@ -0,0 +1,232 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import Path from 'path'; + +import execa from 'execa'; +import { getPackages } from '@kbn/repo-packages'; +import { REPO_ROOT } from '@kbn/repo-info'; + +// --------------------------------------------------------------------------- +// Flat stats JSON schema — must match the output of `buildFlatStatsForPlugin` +// in `@kbn/docs-utils/src/cli/tasks/flat_stats.ts`. +// --------------------------------------------------------------------------- + +/** Shape of a single stat entry in the flat JSON output. */ +export interface FlatStatEntry { + id: string; + label: string; + path: string; + type: string; + lineNumber?: number; + columnNumber?: number; + link: string; +} + +/** Shape of a missing-export entry in the flat JSON output. */ +export interface FlatMissingExportEntry { + source: string; + references: string[]; +} + +/** Complete flat stats JSON written per plugin/package. */ +export interface FlatStats { + counts: { + apiCount: number; + missingExports: number; + missingComments: number; + isAnyType: number; + noReferences: number; + missingReturns: number; + paramDocMismatches: number; + missingComplexTypeInfo: number; + }; + missingComments: FlatStatEntry[]; + isAnyType: FlatStatEntry[]; + noReferences: FlatStatEntry[]; + missingReturns: FlatStatEntry[]; + paramDocMismatches: FlatStatEntry[]; + missingComplexTypeInfo: FlatStatEntry[]; + missingExports: FlatMissingExportEntry[]; +} + +// --------------------------------------------------------------------------- +// Package resolution helpers. +// --------------------------------------------------------------------------- + +export interface PackageInfo { + pkg: ReturnType[number]; + /** The CLI flag to use: `--plugin` for plugins, `--package` for packages. */ + cliFlag: '--plugin' | '--package'; + /** The ID to pass to the CLI (`plugin.id` for plugins, `manifest.id` for packages). */ + cliId: string; +} + +export type FindPackageResult = + | { found: true; info: PackageInfo } + | { found: false; suggestions: string[] }; + +/** + * Finds a package by ID and returns the appropriate CLI flag and ID. + * + * For plugins, returns the `plugin.id` to use with `--plugin`. + * For packages, returns the `manifest.id` to use with `--package`. + */ +export const findPackage = (packageId: string): FindPackageResult => { + const packages = getPackages(REPO_ROOT); + const normalizedId = packageId.toLowerCase(); + + const byManifestId = packages.find((pkg) => pkg.manifest.id === packageId); + if (byManifestId) { + if (byManifestId.isPlugin()) { + return { + found: true, + info: { pkg: byManifestId, cliFlag: '--plugin', cliId: byManifestId.manifest.plugin.id }, + }; + } + return { + found: true, + info: { pkg: byManifestId, cliFlag: '--package', cliId: byManifestId.manifest.id }, + }; + } + + const byPluginId = packages.find((pkg) => pkg.isPlugin() && pkg.manifest.plugin.id === packageId); + if (byPluginId && byPluginId.isPlugin()) { + return { + found: true, + info: { pkg: byPluginId, cliFlag: '--plugin', cliId: byPluginId.manifest.plugin.id }, + }; + } + + const suggestions: string[] = []; + for (const pkg of packages) { + const manifestId = pkg.manifest.id.toLowerCase(); + if (pkg.isPlugin()) { + const pluginId = pkg.manifest.plugin.id.toLowerCase(); + if (pluginId.includes(normalizedId) || manifestId.includes(normalizedId)) { + suggestions.push(`${pkg.manifest.plugin.id} (${pkg.manifest.id})`); + } + } else if (manifestId.includes(normalizedId)) { + suggestions.push(pkg.manifest.id); + } + } + + return { found: false, suggestions: suggestions.slice(0, 5) }; +}; + +/** + * Finds the package that contains a given file path. + */ +export const findPackageForFile = (filePath: string): PackageInfo | undefined => { + const packages = getPackages(REPO_ROOT); + const absolutePath = Path.resolve(REPO_ROOT, filePath); + const pkg = packages.find((p) => absolutePath.startsWith(p.directory)); + + if (!pkg) { + return undefined; + } + + if (pkg.isPlugin()) { + return { pkg, cliFlag: '--plugin', cliId: pkg.manifest.plugin.id }; + } + return { pkg, cliFlag: '--package', cliId: pkg.manifest.id }; +}; + +/** + * Detects the target type based on the target string. + * + * Scoped package names (starting with `@`) are treated as IDs. + * Paths containing `/` are treated as files; otherwise treated as plugin/package IDs. + */ +export const detectTargetType = (target: string): 'file' | 'id' => { + if (target.startsWith('@')) { + return 'id'; + } + if (target.includes('/')) { + return 'file'; + } + return 'id'; +}; + +// --------------------------------------------------------------------------- +// CLI runner. +// --------------------------------------------------------------------------- + +/** + * Runs the `check_package_docs` CLI to generate fresh stats. + */ +export const generateStats = async ( + cliFlag: '--plugin' | '--package', + cliId: string +): Promise<{ success: boolean; error?: string }> => { + try { + await execa('node', ['scripts/check_package_docs', cliFlag, cliId, '--write'], { + cwd: REPO_ROOT, + timeout: 120000, + }); + return { success: true }; + } catch (err: unknown) { + // Exit code 1 means validation failed (issues found) — this is expected. + if ( + err && + typeof err === 'object' && + 'exitCode' in err && + (err as { exitCode: number }).exitCode === 1 + ) { + return { success: true }; + } + + if (err && typeof err === 'object') { + const execaErr = err as { stderr?: string; stdout?: string; message?: string }; + if (execaErr.stderr && execaErr.stderr.trim()) { + return { success: false, error: execaErr.stderr.trim() }; + } + if (execaErr.message) { + return { success: false, error: execaErr.message }; + } + } + + const error = err instanceof Error ? err.message : String(err); + return { success: false, error }; + } +}; + +// --------------------------------------------------------------------------- +// Target resolution — shared across both MCP tools. +// --------------------------------------------------------------------------- + +/** + * Resolves the effective target type and locates the owning package. + * + * @returns The resolved `PackageInfo` and optional `filePath`, or an error string. + */ +export const resolveTarget = ( + target: string, + explicitType?: 'plugin' | 'package' | 'file' +): { pkgInfo: PackageInfo; filePath?: string } | { error: string } => { + const effectiveType = explicitType ?? (detectTargetType(target) === 'file' ? 'file' : 'plugin'); + + if (effectiveType === 'file') { + const pkgInfo = findPackageForFile(target); + if (!pkgInfo) { + return { error: `Could not find a package containing file '${target}'.` }; + } + return { pkgInfo, filePath: target }; + } + + const result = findPackage(target); + if (!result.found) { + let errorMsg = `Plugin or package '${target}' not found.`; + if (result.suggestions.length > 0) { + errorMsg += ` Did you mean: ${result.suggestions.join(', ')}?`; + } + return { error: errorMsg }; + } + return { pkgInfo: result.info }; +}; diff --git a/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/fix_package_docs.test.ts b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/fix_package_docs.test.ts new file mode 100644 index 0000000000000..b0bd3226b3e95 --- /dev/null +++ b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/fix_package_docs.test.ts @@ -0,0 +1,302 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import fs from 'fs'; + +import { fixPackageDocsTool } from './fix_package_docs'; +import { parseToolResultJsonContent } from './test_utils'; + +jest.mock('@kbn/repo-packages', () => ({ + getPackages: jest.fn(() => [ + { + id: '@kbn/test-pkg', + directory: '/repo/packages/test-pkg', + manifest: { id: '@kbn/test-pkg' }, + isPlugin: (): boolean => false, + }, + { + id: '@kbn/dashboard-plugin', + directory: '/repo/src/platform/plugins/shared/dashboard', + manifest: { id: '@kbn/dashboard-plugin', plugin: { id: 'dashboard' } }, + isPlugin: (): boolean => true, + }, + ]), +})); + +jest.mock('@kbn/repo-info', () => ({ + REPO_ROOT: '/repo', +})); + +jest.mock('execa', () => jest.fn().mockResolvedValue({ exitCode: 0 })); + +jest.mock('fs', () => ({ + existsSync: jest.fn(), + readFileSync: jest.fn(), +})); + +const mockFs = fs as jest.Mocked; + +const createMockStats = (overrides = {}) => ({ + counts: { + apiCount: 10, + missingExports: 0, + missingComments: 2, + isAnyType: 1, + noReferences: 0, + missingReturns: 1, + paramDocMismatches: 1, + missingComplexTypeInfo: 0, + }, + missingComments: [ + { + id: 'def-public.fn1', + label: 'fn1', + path: 'packages/test-pkg/src/index.ts', + type: 'Function', + lineNumber: 10, + columnNumber: 1, + link: 'https://github.com/elastic/kibana/blob/main/packages/test-pkg/src/index.ts#L10', + }, + { + id: 'def-public.fn2', + label: 'fn2', + path: 'packages/test-pkg/src/utils.ts', + type: 'Function', + lineNumber: 5, + columnNumber: 1, + link: 'https://github.com/elastic/kibana/blob/main/packages/test-pkg/src/utils.ts#L5', + }, + ], + isAnyType: [ + { + id: 'def-public.badFn', + label: 'badFn', + path: 'packages/test-pkg/src/index.ts', + type: 'Function', + lineNumber: 20, + columnNumber: 1, + link: 'https://github.com/elastic/kibana/blob/main/packages/test-pkg/src/index.ts#L20', + }, + ], + noReferences: [], + missingReturns: [ + { + id: 'def-public.fn1', + label: 'fn1', + path: 'packages/test-pkg/src/index.ts', + type: 'Function', + lineNumber: 10, + columnNumber: 1, + link: 'https://github.com/elastic/kibana/blob/main/packages/test-pkg/src/index.ts#L10', + }, + ], + paramDocMismatches: [ + { + id: 'def-public.fn3', + label: 'fn3', + path: 'packages/test-pkg/src/index.ts', + type: 'Function', + lineNumber: 30, + columnNumber: 1, + link: 'https://github.com/elastic/kibana/blob/main/packages/test-pkg/src/index.ts#L30', + }, + ], + missingComplexTypeInfo: [], + missingExports: [], + ...overrides, +}); + +describe('fixPackageDocsTool', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('target parameter', () => { + it('returns error when package is not found', async () => { + const result = await fixPackageDocsTool.handler({ target: '@kbn/unknown' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toContain("'@kbn/unknown' not found"); + }); + + it('finds package by manifest ID', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await fixPackageDocsTool.handler({ target: '@kbn/test-pkg' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toBeUndefined(); + expect(parsed.package).toBe('@kbn/test-pkg'); + }); + + it('finds plugin by plugin ID', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await fixPackageDocsTool.handler({ target: 'dashboard' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toBeUndefined(); + expect(parsed.package).toBe('@kbn/dashboard-plugin'); + }); + }); + + describe('type parameter', () => { + it('treats target as file when type is "file"', async () => { + const result = await fixPackageDocsTool.handler({ + target: '/some/random/file.ts', + type: 'file', + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toContain('Could not find a package'); + }); + + it('auto-detects file type when target contains "/"', async () => { + const result = await fixPackageDocsTool.handler({ + target: '/some/random/file.ts', + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toContain('Could not find a package'); + }); + }); + + describe('stats file handling', () => { + it('returns error when stats file does not exist', async () => { + mockFs.existsSync.mockReturnValue(false); + + const result = await fixPackageDocsTool.handler({ target: '@kbn/test-pkg' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.error).toContain('Stats file not found'); + }); + }); + + describe('issues grouping', () => { + it('returns issues grouped by file', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await fixPackageDocsTool.handler({ target: '@kbn/test-pkg' }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.package).toBe('@kbn/test-pkg'); + expect(parsed.totalIssues).toBe(5); // 2 + 1 + 1 + 1 + expect(parsed.issuesByFile).toHaveLength(2); // index.ts and utils.ts + + const indexFile = parsed.issuesByFile.find((g: { file: string }) => + g.file.includes('index.ts') + ); + expect(indexFile).toBeDefined(); + expect(indexFile.issues.length).toBeGreaterThan(0); + }); + }); + + describe('issue type filtering', () => { + it('filters by issue type when specified', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await fixPackageDocsTool.handler({ + target: '@kbn/test-pkg', + issueTypes: ['missingReturns'], + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.totalIssues).toBe(1); + const allIssueTypes = parsed.issuesByFile.flatMap((g: { issues: { issueType: string }[] }) => + g.issues.map((i) => i.issueType) + ); + expect(allIssueTypes).toEqual(['missingReturns']); + }); + + it('includes templates for actionable issues', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await fixPackageDocsTool.handler({ + target: '@kbn/test-pkg', + issueTypes: ['missingReturns'], + }); + const parsed = parseToolResultJsonContent(result); + + const issue = parsed.issuesByFile[0].issues[0]; + expect(issue.template).toBe('@returns {TYPE}'); + }); + + it('includes missingExports when requested and counts them in totalIssues', async () => { + const statsWithExports = createMockStats({ + missingExports: [ + { source: 'SomeType', references: ['file1.ts', 'file2.ts'] }, + { source: 'OtherType', references: ['file3.ts'] }, + ], + }); + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(statsWithExports)); + + const result = await fixPackageDocsTool.handler({ + target: '@kbn/test-pkg', + issueTypes: ['missingExports'], + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.missingExports).toHaveLength(2); + expect(parsed.missingExports[0].source).toBe('SomeType'); + // totalIssues should include missingExports when explicitly requested. + expect(parsed.totalIssues).toBe(2); + }); + }); + + describe('file filtering', () => { + it('filters issues by file path using relative path', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await fixPackageDocsTool.handler({ + target: 'packages/test-pkg/src/index.ts', + type: 'file', + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.package).toBe('@kbn/test-pkg'); + expect(parsed.file).toBe('packages/test-pkg/src/index.ts'); + // Should only include issues from index.ts (fn1 missingComments, badFn isAnyType, fn1 missingReturns, fn3 paramDocMismatches). + expect(parsed.totalIssues).toBe(4); + expect(parsed.issuesByFile).toHaveLength(1); + expect(parsed.issuesByFile[0].file).toBe('packages/test-pkg/src/index.ts'); + }); + + it('filters issues by file path using absolute path', async () => { + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue(JSON.stringify(createMockStats())); + + const result = await fixPackageDocsTool.handler({ + target: '/repo/packages/test-pkg/src/utils.ts', + type: 'file', + }); + const parsed = parseToolResultJsonContent(result); + + expect(parsed.package).toBe('@kbn/test-pkg'); + // Should only include issues from utils.ts (fn2 missingComments). + expect(parsed.totalIssues).toBe(1); + expect(parsed.issuesByFile).toHaveLength(1); + expect(parsed.issuesByFile[0].file).toBe('packages/test-pkg/src/utils.ts'); + }); + }); + + describe('tool metadata', () => { + it('has correct tool metadata', () => { + expect(fixPackageDocsTool.name).toBe('fix_package_docs'); + expect(fixPackageDocsTool.description).toContain('documentation issues'); + expect(fixPackageDocsTool.description).toContain('source context'); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/fix_package_docs.ts b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/fix_package_docs.ts new file mode 100644 index 0000000000000..77d6fd9946c01 --- /dev/null +++ b/src/platform/packages/shared/kbn-mcp-dev-server/src/tools/fix_package_docs.ts @@ -0,0 +1,243 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import fs from 'fs'; +import Path from 'path'; + +import { z } from '@kbn/zod'; +import { REPO_ROOT } from '@kbn/repo-info'; + +import type { ToolDefinition } from '../types'; +import { type FlatStatEntry, type FlatStats, generateStats, resolveTarget } from './docs_utils'; + +interface EnrichedIssue { + issueType: string; + id: string; + label: string; + file: string; + line?: number; + column?: number; + link: string; + type: string; + sourceSnippet?: string; + template?: string; +} + +interface FileGroup { + file: string; + issues: EnrichedIssue[]; +} + +const SNIPPET_CONTEXT_LINES = 3; + +/** + * Reads a few lines of source context around a given line number. + */ +const getSourceSnippet = (filePath: string, lineNumber?: number): string | undefined => { + if (!lineNumber) { + return undefined; + } + + const absolutePath = Path.resolve(REPO_ROOT, filePath); + if (!fs.existsSync(absolutePath)) { + return undefined; + } + + try { + const content = fs.readFileSync(absolutePath, 'utf-8'); + const lines = content.split('\n'); + const startLine = Math.max(0, lineNumber - SNIPPET_CONTEXT_LINES - 1); + const endLine = Math.min(lines.length, lineNumber + SNIPPET_CONTEXT_LINES); + return lines + .slice(startLine, endLine) + .map((line, idx) => `${startLine + idx + 1}| ${line}`) + .join('\n'); + } catch { + return undefined; + } +}; + +/** + * Generates a mechanical template for the agent to complete. + */ +const getTemplate = (issueType: string, entry: FlatStatEntry): string | undefined => { + switch (issueType) { + case 'missingReturns': + return `@returns {TYPE}`; + case 'missingComments': + return `/** Description for ${entry.label}. */`; + case 'paramDocMismatches': + return `@param {TYPE} paramName -`; + case 'missingComplexTypeInfo': + return `/** Description for ${entry.label}. */`; + default: + return undefined; + } +}; + +/** + * Enriches a stat entry with source snippet and template. + */ +const enrichEntry = (issueType: string, entry: FlatStatEntry): EnrichedIssue => ({ + issueType, + id: entry.id, + label: entry.label, + file: entry.path, + line: entry.lineNumber, + column: entry.columnNumber, + link: entry.link, + type: entry.type, + sourceSnippet: getSourceSnippet(entry.path, entry.lineNumber), + template: getTemplate(issueType, entry), +}); + +/** + * Groups issues by file path. + */ +const groupByFile = (issues: EnrichedIssue[]): FileGroup[] => { + const groups = new Map(); + + for (const issue of issues) { + const existing = groups.get(issue.file) ?? []; + existing.push(issue); + groups.set(issue.file, existing); + } + + return Array.from(groups.entries()) + .map(([file, fileIssues]) => ({ file, issues: fileIssues })) + .sort((a, b) => a.file.localeCompare(b.file)); +}; + +const fixPackageDocsInputSchema = z.object({ + target: z + .string() + .describe( + 'The plugin ID (e.g., "dashboard"), manifest ID (e.g., "@kbn/dashboard-plugin"), or file path to get issues for.' + ), + type: z + .enum(['plugin', 'package', 'file']) + .optional() + .describe( + 'How to interpret the target. If omitted, auto-detected: paths containing "/" are treated as files; otherwise tries plugin ID first, then manifest ID.' + ), + issueTypes: z + .array( + z.enum([ + 'missingComments', + 'missingReturns', + 'paramDocMismatches', + 'missingComplexTypeInfo', + 'isAnyType', + 'missingExports', + ]) + ) + .optional() + .describe( + 'Filter to specific issue types. Defaults to actionable types (missingComments, missingReturns, paramDocMismatches, missingComplexTypeInfo, isAnyType). Use "missingExports" explicitly to include export issues, which are informational and often require changes in consuming packages.' + ), +}); + +const fixPackageDocs = async (input: z.infer) => { + const resolved = resolveTarget(input.target, input.type); + if ('error' in resolved) { + return { error: resolved.error }; + } + + const { pkgInfo, filePath } = resolved; + const { pkg, cliFlag, cliId } = pkgInfo; + + const genResult = await generateStats(cliFlag, cliId); + if (!genResult.success) { + return { error: `Failed to generate stats: ${genResult.error}` }; + } + + const statsPath = Path.resolve(pkg.directory, 'target', 'api_docs', 'stats.json'); + + if (!fs.existsSync(statsPath)) { + return { + error: `Stats file not found at ${statsPath}. This may indicate an issue with the package.`, + }; + } + + let stats: FlatStats; + try { + stats = JSON.parse(fs.readFileSync(statsPath, 'utf-8')); + } catch { + return { error: `Failed to parse stats file at ${statsPath}.` }; + } + + const defaultIssueTypes = [ + 'missingComments', + 'missingReturns', + 'paramDocMismatches', + 'missingComplexTypeInfo', + 'isAnyType', + ] as const; + + const issueTypesToInclude = input.issueTypes ?? defaultIssueTypes; + + let allIssues: EnrichedIssue[] = []; + + if (issueTypesToInclude.includes('missingComments')) { + allIssues.push(...stats.missingComments.map((e) => enrichEntry('missingComments', e))); + } + if (issueTypesToInclude.includes('missingReturns')) { + allIssues.push(...stats.missingReturns.map((e) => enrichEntry('missingReturns', e))); + } + if (issueTypesToInclude.includes('paramDocMismatches')) { + allIssues.push(...stats.paramDocMismatches.map((e) => enrichEntry('paramDocMismatches', e))); + } + if (issueTypesToInclude.includes('missingComplexTypeInfo')) { + allIssues.push( + ...stats.missingComplexTypeInfo.map((e) => enrichEntry('missingComplexTypeInfo', e)) + ); + } + if (issueTypesToInclude.includes('isAnyType')) { + allIssues.push(...stats.isAnyType.map((e) => enrichEntry('isAnyType', e))); + } + + if (filePath) { + const normalizedPath = filePath.startsWith('/') ? filePath : Path.resolve(REPO_ROOT, filePath); + const relativePath = Path.relative(REPO_ROOT, normalizedPath); + allIssues = allIssues.filter((issue) => issue.file === relativePath || issue.file === filePath); + } + + const groupedIssues = groupByFile(allIssues); + + const includeMissingExports = input.issueTypes?.includes('missingExports') ?? false; + const missingExports = includeMissingExports ? stats.missingExports : []; + const totalIssues = allIssues.length + missingExports.length; + + return { + package: pkg.id, + directory: pkg.directory, + file: filePath, + totalIssues, + issuesByFile: groupedIssues, + missingExports, + }; +}; + +export const fixPackageDocsTool: ToolDefinition = { + name: 'fix_package_docs', + description: + 'Get detailed documentation issues for a Kibana plugin, package, or file. Returns issues grouped by file with source context and fix templates. Use this after check_package_docs identifies problems.', + inputSchema: fixPackageDocsInputSchema, + handler: async (input) => { + const result = await fixPackageDocs(input); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, +};