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 2dd29f8550c9a..326f72285dfb8 100644 --- a/packages/kbn-docs-utils/src/build_api_docs_cli.ts +++ b/packages/kbn-docs-utils/src/build_api_docs_cli.ts @@ -7,40 +7,25 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import Fs from 'fs'; -import Fsp from 'fs/promises'; import Path from 'path'; import apm, { type Transaction } from 'elastic-apm-node'; -import { Project } from 'ts-morph'; import { run } from '@kbn/dev-cli-runner'; -import { createFlagError } from '@kbn/dev-cli-errors'; -import { CiStatsReporter } from '@kbn/ci-stats-reporter'; import { REPO_ROOT } from '@kbn/repo-info'; import { initApm } from '@kbn/apm-config-loader'; -import { writePluginDocs } from './mdx/write_plugin_mdx_docs'; -import type { ApiDeclaration, ApiStats, PluginMetaInfo } from './types'; -import { findPlugins } from './find_plugins'; import { pathsOutsideScopes } from './build_api_declarations/utils'; -import { getPluginApiMap } from './get_plugin_api_map'; -import { writeDeprecationDocByApi } from './mdx/write_deprecations_doc_by_api'; -import { writeDeprecationDocByPlugin } from './mdx/write_deprecations_doc_by_plugin'; -import { writePluginDirectoryDoc } from './mdx/write_plugin_directory_doc'; -import { collectApiStatsForPlugin } from './stats'; -import type { EslintDisableCounts } from './count_eslint_disable'; -import { countEslintDisableLines } from './count_eslint_disable'; -import { writeDeprecationDueByTeam } from './mdx/write_deprecations_due_by_team'; -import { trimDeletedDocsFromNav } from './trim_deleted_docs_from_nav'; -import { getAllDocFileIds } from './mdx/get_all_doc_file_ids'; -import { getPathsByPackage } from './get_paths_by_package'; -import type { EnzymeImportCounts } from './count_enzyme_imports'; -import { countEnzymeImports } from './count_enzyme_imports'; - -function isStringArray(arr: unknown | string[]): arr is string[] { - return Array.isArray(arr) && arr.every((p) => typeof p === 'string'); -} +import { + parseCliFlags, + setupProject, + buildApiMap, + collectStats, + writeDocs, + reportMetrics, + type CliFlags, + type CliContext, +} from './cli'; const rootDir = Path.join(__dirname, '../../..'); initApm(process.argv, rootDir, false, 'build_api_docs_cli'); @@ -59,381 +44,56 @@ export function runBuildApiDocsCli() { const transaction = apm.startTransaction('build-api-docs', 'kibana-cli'); const spanSetup = transaction.startSpan('build_api_docs.setup', 'setup'); - const collectReferences = flags.references as boolean; - const stats = flags.stats && typeof flags.stats === 'string' ? [flags.stats] : flags.stats; - const pluginFilter = - flags.plugin && typeof flags.plugin === 'string' - ? [flags.plugin] - : (flags.plugin as string[] | undefined); - - if (pluginFilter && !isStringArray(pluginFilter)) { + let options; + try { + options = parseCliFlags(flags as CliFlags); + } catch (error) { await endTransactionWithFailure(transaction); - throw createFlagError('expected --plugin must only contain strings'); - } - - if ( - (stats && - isStringArray(stats) && - stats.find((s) => s !== 'any' && s !== 'comments' && s !== 'exports')) || - (stats && !isStringArray(stats)) - ) { - await endTransactionWithFailure(transaction); - throw createFlagError( - 'expected --stats must only contain `any`, `comments` and/or `exports`' - ); + throw error; } const outputFolder = Path.resolve(REPO_ROOT, 'api_docs'); - spanSetup?.end(); - const spanInitialDocIds = transaction.startSpan('build_api_docs.initialDocIds', 'setup'); - - const initialDocIds = - !pluginFilter && Fs.existsSync(outputFolder) - ? await getAllDocFileIds(outputFolder) - : undefined; - - spanInitialDocIds?.end(); - const spanPlugins = transaction.startSpan('build_api_docs.findPlugins', 'setup'); + const context: CliContext = { + log, + transaction, + outputFolder, + }; - const plugins = findPlugins(stats && pluginFilter ? pluginFilter : undefined); - - if (stats && Array.isArray(pluginFilter) && pluginFilter.length !== plugins.length) { - await endTransactionWithFailure(transaction); - throw createFlagError('expected --plugin was not found'); - } - - spanPlugins?.end(); - - const spanPathsByPackage = transaction.startSpan('build_api_docs.getPathsByPackage', 'setup'); - - const pathsByPlugin = await getPathsByPackage(plugins); - - spanPathsByPackage?.end(); + spanSetup?.end(); - const spanProject = transaction.startSpan('build_api_docs.getTsProject', 'setup'); + // Setup project: discover plugins, resolve paths, create TypeScript project + const setupResult = await setupProject(context, options); - const project = getTsProject( - REPO_ROOT, - stats && pluginFilter && plugins.length === 1 ? plugins[0].directory : undefined + // Build API map: analyze TypeScript and extract API declarations + const apiMapResult = buildApiMap( + setupResult.project, + setupResult.plugins, + log, + transaction, + options ); - spanProject?.end(); - - const spanFolders = transaction.startSpan('build_api_docs.check-folders', 'setup'); - - // if the output folder already exists, and we don't have a plugin filter, delete all the files in the output folder - if (Fs.existsSync(outputFolder) && !pluginFilter) { - await Fsp.rm(outputFolder, { recursive: true }); - } - - // if the output folder doesn't exist, create it - if (!Fs.existsSync(outputFolder)) { - await Fsp.mkdir(outputFolder, { recursive: true }); - } - - spanFolders?.end(); - const spanPluginApiMap = transaction.startSpan('build_api_docs.getPluginApiMap', 'setup'); - - const { - pluginApiMap, - missingApiItems, - unreferencedDeprecations, - referencedDeprecations, - adoptionTrackedAPIs, - } = getPluginApiMap(project, plugins, log, { collectReferences, pluginFilter }); - - spanPluginApiMap?.end(); - - const reporter = CiStatsReporter.fromEnv(log); - - const allPluginStats: { - [key: string]: PluginMetaInfo & ApiStats & EslintDisableCounts & EnzymeImportCounts; - } = {}; - for (const plugin of plugins) { - const id = plugin.id; - - if (stats && pluginFilter && !pluginFilter.includes(plugin.id)) { - continue; - } - - const spanApiStatsForPlugin = transaction.startSpan( - `build_api_docs.collectApiStatsForPlugin-${id}`, - 'stats' - ); - - const pluginApi = pluginApiMap[id]; - const paths = pathsByPlugin.get(plugin) ?? []; - - allPluginStats[id] = { - ...(await countEslintDisableLines(paths)), - ...(await countEnzymeImports(paths)), - ...collectApiStatsForPlugin( - pluginApi, - missingApiItems, - referencedDeprecations, - adoptionTrackedAPIs - ), - owner: plugin.manifest.owner, - description: plugin.manifest.description, - isPlugin: plugin.isPlugin, - }; - - spanApiStatsForPlugin?.end(); - } - - if (!stats) { - const spanWritePluginDirectoryDoc = transaction.startSpan( - 'build_api_docs.writePluginDirectoryDoc', - 'write' - ); - - await writePluginDirectoryDoc(outputFolder, pluginApiMap, allPluginStats, log); - - spanWritePluginDirectoryDoc?.end(); - } - - for (const plugin of plugins) { - // Note that the filtering is done here, and not above 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 (pluginFilter && !pluginFilter.includes(plugin.id)) { - continue; - } - - const id = plugin.id; - const pluginApi = pluginApiMap[id]; - const pluginStats = allPluginStats[id]; - const pluginTeam = plugin.manifest.owner.name; - - const spanMetrics = transaction.startSpan( - `build_api_docs.collectApiStatsForPlugin-${id}`, - 'stats' - ); - - reporter.metrics([ - { - id, - meta: { pluginTeam }, - group: 'Unreferenced deprecated APIs', - value: unreferencedDeprecations[id] ? unreferencedDeprecations[id].length : 0, - }, - { - id, - meta: { pluginTeam }, - group: 'API count', - value: pluginStats.apiCount, - }, - { - id, - meta: { pluginTeam }, - group: 'API count missing comments', - value: pluginStats.missingComments.length, - }, - { - id, - meta: { pluginTeam }, - group: 'API count with any type', - value: pluginStats.isAnyType.length, - }, - { - id, - meta: { pluginTeam }, - group: 'Non-exported public API item count', - value: missingApiItems[id] ? Object.keys(missingApiItems[id]).length : 0, - }, - { - id, - meta: { pluginTeam }, - group: 'References to deprecated APIs', - value: pluginStats.deprecatedAPIsReferencedCount, - }, - { - id, - meta: { - pluginTeam, - // `meta` only allows primitives or string[] - // Also, each string is allowed to have a max length of 2056, - // so it's safer to stringify each element in the array over sending the entire array as stringified. - // My internal tests with 4 plugins using the same API gets to a length of 156 chars, - // so we should have enough room for tracking popular APIs. - // TODO: We can do a follow-up improvement to split the report if we find out we might hit the limit. - adoptionTrackedAPIs: pluginStats.adoptionTrackedAPIs.map((metric) => - JSON.stringify(metric) - ), - }, - group: 'Adoption-tracked APIs', - value: pluginStats.adoptionTrackedAPIsCount, - }, - { - id, - meta: { pluginTeam }, - group: 'Adoption-tracked APIs that are not used anywhere', - value: pluginStats.adoptionTrackedAPIsUnreferencedCount, - }, - { - id, - meta: { pluginTeam }, - group: 'ESLint disabled line counts', - value: pluginStats.eslintDisableLineCount, - }, - { - id, - meta: { pluginTeam }, - group: 'ESLint disabled in files', - value: pluginStats.eslintDisableFileCount, - }, - { - id, - meta: { pluginTeam }, - group: 'Total ESLint disabled count', - value: pluginStats.eslintDisableFileCount + pluginStats.eslintDisableLineCount, - }, - { - id, - meta: { pluginTeam }, - group: 'Enzyme imports', - value: pluginStats.enzymeImportCount, - }, - ]); - - const getLink = (d: ApiDeclaration) => - `https://github.com/elastic/kibana/tree/main/${d.path}#:~:text=${encodeURIComponent( - d.label - )}`; - - if (collectReferences && pluginFilter?.includes(plugin.id)) { - if (referencedDeprecations[id] && pluginStats.deprecatedAPIsReferencedCount > 0) { - log.info(`${referencedDeprecations[id].length} deprecated APIs used`); - // eslint-disable-next-line no-console - console.table(referencedDeprecations[id]); - } else { - log.info(`No referenced deprecations for plugin ${plugin.id}`); - } - if (pluginStats.noReferences.length > 0) { - // eslint-disable-next-line no-console - console.table( - pluginStats.noReferences.map((d) => ({ - id: d.id, - link: getLink(d), - })) - ); - } else { - log.info(`No unused APIs for plugin ${plugin.id}`); - } - } - - if (stats) { - const passesAllChecks = - pluginStats.isAnyType.length === 0 && - pluginStats.missingComments.length === 0 && - pluginStats.deprecatedAPIsReferencedCount === 0 && - (!missingApiItems[id] || Object.keys(missingApiItems[id]).length === 0); - - log.info(`--- Plugin '${id}' ${passesAllChecks ? ` passes all checks ----` : '----`'}`); - - if (!passesAllChecks) { - log.info(`${pluginStats.isAnyType.length} API items with ANY`); - - if (stats.includes('any')) { - // eslint-disable-next-line no-console - console.table( - pluginStats.isAnyType.map((d) => ({ - id: d.id, - link: getLink(d), - })) - ); - } - - log.info(`${pluginStats.missingComments.length} API items missing comments`); - if (stats.includes('comments')) { - // eslint-disable-next-line no-console - console.table( - pluginStats.missingComments.map((d) => ({ - id: d.id, - link: getLink(d), - })) - ); - } - - if (missingApiItems[id]) { - log.info( - `${Object.keys(missingApiItems[id]).length} referenced API items not exported` - ); - if (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(', '), - })) - ); - } - } - } - } - - spanMetrics?.end(); - - if (!stats) { - if (pluginStats.apiCount > 0) { - log.info(`Writing public API doc for plugin ${pluginApi.id}.`); - - const spanWritePluginDocs = transaction.startSpan( - 'build_api_docs.writePluginDocs', - 'write' - ); - - await writePluginDocs(outputFolder, { doc: pluginApi, plugin, pluginStats, log }); - - spanWritePluginDocs?.end(); - } else { - log.info(`Plugin ${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(); + // Collect stats: gather statistics for all plugins + const allPluginStats = await collectStats( + setupResult, + apiMapResult, + log, + transaction, + options + ); - const spanWriteDeprecationDocByApi = transaction.startSpan( - 'build_api_docs.writeDeprecationDocByApi', - 'write' - ); + // Report metrics: send to CI stats and log validation results + reportMetrics(setupResult, apiMapResult, allPluginStats, log, transaction, options); - await writeDeprecationDocByApi( - outputFolder, - referencedDeprecations, - unreferencedDeprecations, - log - ); - - spanWriteDeprecationDocByApi?.end(); - } - } + // Write docs: generate all documentation files + await writeDocs(context, setupResult, apiMapResult, allPluginStats, options); if (Object.values(pathsOutsideScopes).length > 0) { log.warning(`Found paths outside of normal scope folders:`); log.warning(pathsOutsideScopes); } - if (initialDocIds) { - await trimDeletedDocsFromNav(log, initialDocIds, outputFolder); - } - transaction.end(); }, { @@ -454,30 +114,3 @@ export function runBuildApiDocsCli() { } ); } - -function getTsProject(repoPath: string, overridePath?: string) { - const xpackTsConfig = !overridePath - ? `${repoPath}/tsconfig.json` - : `${overridePath}/tsconfig.json`; - - const project = new Project({ - tsConfigFilePath: xpackTsConfig, - // We'll use the files added below instead. - skipAddingFilesFromTsConfig: true, - }); - - if (!overridePath) { - 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']); - project.addSourceFilesAtPaths([`${repoPath}/x-pack/solutions/**/*.ts`, '!**/*.d.ts']); - project.addSourceFilesAtPaths([`${repoPath}/src/plugins/**/*.ts`, '!**/*.d.ts']); - project.addSourceFilesAtPaths([`${repoPath}/src/platform/**/*.ts`, '!**/*.d.ts']); - project.addSourceFilesAtPaths([`${repoPath}/src/core/packages/**/*.ts`, '!**/*.d.ts']); - project.addSourceFilesAtPaths([`${repoPath}/packages/**/*.ts`, '!**/*.d.ts']); - } else { - project.addSourceFilesAtPaths([`${overridePath}/**/*.ts`, '!**/*.d.ts']); - } - project.resolveSourceFileDependencies(); - return project; -} diff --git a/packages/kbn-docs-utils/src/cli/index.ts b/packages/kbn-docs-utils/src/cli/index.ts new file mode 100644 index 0000000000000..f110928d4d37f --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/index.ts @@ -0,0 +1,12 @@ +/* + * 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". + */ + +export { parseCliFlags } from './parse_cli_flags'; +export type { CliFlags, CliOptions, CliContext } from './types'; +export { setupProject, buildApiMap, collectStats, writeDocs, reportMetrics } from './tasks'; diff --git a/packages/kbn-docs-utils/src/cli/parse_cli_flags.test.ts b/packages/kbn-docs-utils/src/cli/parse_cli_flags.test.ts new file mode 100644 index 0000000000000..1c4c79e51190b --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/parse_cli_flags.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { parseCliFlags } from './parse_cli_flags'; +import type { CliFlags } from './types'; + +describe('parseCliFlags', () => { + it('parses valid flags correctly', () => { + const flags: CliFlags = { + references: true, + stats: ['any', 'comments'], + plugin: ['plugin1', 'plugin2'], + }; + + const result = parseCliFlags(flags); + + expect(result.collectReferences).toBe(true); + expect(result.stats).toEqual(['any', 'comments']); + expect(result.pluginFilter).toEqual(['plugin1', 'plugin2']); + }); + + it('normalizes single string plugin to array', () => { + const flags: CliFlags = { + plugin: 'single-plugin', + }; + + const result = parseCliFlags(flags); + + expect(result.pluginFilter).toEqual(['single-plugin']); + }); + + it('normalizes single string stats to array', () => { + const flags: CliFlags = { + stats: 'any', + }; + + const result = parseCliFlags(flags); + + expect(result.stats).toEqual(['any']); + }); + + it('handles undefined flags', () => { + const flags: CliFlags = {}; + + const result = parseCliFlags(flags); + + expect(result.collectReferences).toBe(false); + expect(result.stats).toBeUndefined(); + expect(result.pluginFilter).toBeUndefined(); + }); + + it('throws error for invalid plugin filter type', () => { + const flags: CliFlags = { + plugin: { invalid: 'object' } as any, + }; + + expect(() => parseCliFlags(flags)).toThrow('expected --plugin must only contain strings'); + }); + + it('throws error for invalid stats values', () => { + const flags: CliFlags = { + stats: ['invalid-value'], + }; + + expect(() => parseCliFlags(flags)).toThrow( + 'expected --stats must only contain `any`, `comments` and/or `exports`' + ); + }); + + it('throws error for invalid stats type', () => { + const flags: CliFlags = { + stats: { invalid: 'object' } as any, + }; + + expect(() => parseCliFlags(flags)).toThrow( + 'expected --stats must only contain `any`, `comments` and/or `exports`' + ); + }); + + it('accepts valid stats values', () => { + const flags: CliFlags = { + stats: ['any', 'comments', 'exports'], + }; + + const result = parseCliFlags(flags); + + expect(result.stats).toEqual(['any', 'comments', 'exports']); + }); + + it('handles references flag correctly', () => { + const flags: CliFlags = { + references: true, + }; + + const result = parseCliFlags(flags); + + expect(result.collectReferences).toBe(true); + }); + + it('handles references flag as false when not provided', () => { + const flags: CliFlags = {}; + + const result = parseCliFlags(flags); + + expect(result.collectReferences).toBe(false); + }); +}); diff --git a/packages/kbn-docs-utils/src/cli/parse_cli_flags.ts b/packages/kbn-docs-utils/src/cli/parse_cli_flags.ts new file mode 100644 index 0000000000000..0bd25c64a8bfe --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/parse_cli_flags.ts @@ -0,0 +1,53 @@ +/* + * 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 { createFlagError } from '@kbn/dev-cli-errors'; +import type { CliFlags, CliOptions } from './types'; + +/** + * Validates that an array contains only strings. + */ +function isStringArray(arr: unknown | string[]): arr is string[] { + return Array.isArray(arr) && arr.every((p) => typeof p === 'string'); +} + +/** + * Parses and validates CLI flags, normalizing them into a consistent format. + * + * @param flags - Raw flags from the CLI runner. + * @returns Validated and normalized CLI options. + * @throws {Error} If flags are invalid. + */ +export function parseCliFlags(flags: CliFlags): CliOptions { + const collectReferences = flags.references === true; + const stats = flags.stats && typeof flags.stats === 'string' ? [flags.stats] : flags.stats; + const pluginFilter = + flags.plugin && typeof flags.plugin === 'string' + ? [flags.plugin] + : (flags.plugin as string[] | undefined); + + if (pluginFilter && !isStringArray(pluginFilter)) { + throw createFlagError('expected --plugin must only contain strings'); + } + + if ( + (stats && + isStringArray(stats) && + stats.find((s) => s !== 'any' && s !== 'comments' && s !== 'exports')) || + (stats && !isStringArray(stats)) + ) { + throw createFlagError('expected --stats must only contain `any`, `comments` and/or `exports`'); + } + + return { + collectReferences, + stats: stats && isStringArray(stats) ? stats : undefined, + pluginFilter, + }; +} 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 new file mode 100644 index 0000000000000..495960ad9e59f --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/build_api_map.test.ts @@ -0,0 +1,102 @@ +/* + * 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 { Project } from 'ts-morph'; +import { ToolingLog } from '@kbn/tooling-log'; +import { buildApiMap } from './build_api_map'; +import type { CliOptions } from '../types'; + +// Mock getPluginApiMap +jest.mock('../../get_plugin_api_map', () => ({ + getPluginApiMap: jest.fn(() => ({ + pluginApiMap: {}, + missingApiItems: {}, + referencedDeprecations: {}, + unreferencedDeprecations: {}, + adoptionTrackedAPIs: {}, + })), +})); + +import { getPluginApiMap } from '../../get_plugin_api_map'; + +describe('buildApiMap', () => { + let project: Project; + let log: ToolingLog; + let transaction: any; + let plugins: any[]; + + beforeEach(() => { + project = new Project({ + useInMemoryFileSystem: true, + }); + + log = new ToolingLog({ + level: 'silent', + writeTo: process.stdout, + }); + + transaction = { + startSpan: jest.fn(() => ({ + end: jest.fn(), + })), + }; + + plugins = [ + { + id: 'test-plugin', + directory: 'src/plugins/test', + isPlugin: true, + manifest: { + id: 'test-plugin', + owner: { name: 'test-team' }, + serviceFolders: [], + }, + }, + ]; + }); + + it('calls getPluginApiMap with correct parameters', () => { + const options: CliOptions = { + collectReferences: true, + pluginFilter: ['test-plugin'], + }; + + buildApiMap(project, plugins, log, transaction, options); + + expect(getPluginApiMap).toHaveBeenCalledWith(project, plugins, log, { + collectReferences: true, + pluginFilter: ['test-plugin'], + }); + }); + + it('returns result from getPluginApiMap', () => { + const options: CliOptions = { + collectReferences: false, + }; + + const result = buildApiMap(project, plugins, log, transaction, options); + + expect(result).toBeDefined(); + expect(result.pluginApiMap).toBeDefined(); + expect(result.missingApiItems).toBeDefined(); + expect(result.referencedDeprecations).toBeDefined(); + expect(result.unreferencedDeprecations).toBeDefined(); + expect(result.adoptionTrackedAPIs).toBeDefined(); + }); + + it('creates APM span for tracking', () => { + const options: CliOptions = { + collectReferences: false, + }; + + buildApiMap(project, 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 new file mode 100644 index 0000000000000..8fa5444ba1533 --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/build_api_map.ts @@ -0,0 +1,62 @@ +/* + * 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 { Transaction } from 'elastic-apm-node'; +import type { Project } from 'ts-morph'; +import type { ToolingLog } from '@kbn/tooling-log'; +import { getPluginApiMap } from '../../get_plugin_api_map'; +import type { PluginOrPackage } from '../../types'; +import type { BuildApiMapResult, CliOptions } from '../types'; + +/** + * Builds the plugin API map by analyzing TypeScript code. + * + * This task: + * - Analyzes TypeScript source files using the project + * - Extracts API declarations from plugins + * - Collects missing API items, deprecations, and adoption-tracked APIs + * - Optionally collects references between APIs + * + * @param project - TypeScript project instance. + * @param plugins - List of plugins and packages to analyze. + * @param log - Tooling log instance. + * @param transaction - APM transaction for tracking. + * @param options - CLI options including collectReferences and pluginFilter. + * @returns Built API map with all collected metadata. + */ +export function buildApiMap( + project: Project, + plugins: PluginOrPackage[], + log: ToolingLog, + transaction: Transaction, + options: CliOptions +): BuildApiMapResult { + const spanPluginApiMap = transaction.startSpan('build_api_docs.getPluginApiMap', 'setup'); + + const { + pluginApiMap, + missingApiItems, + unreferencedDeprecations, + referencedDeprecations, + adoptionTrackedAPIs, + } = getPluginApiMap(project, plugins, log, { + collectReferences: options.collectReferences, + pluginFilter: options.pluginFilter, + }); + + spanPluginApiMap?.end(); + + return { + pluginApiMap, + missingApiItems, + referencedDeprecations, + unreferencedDeprecations, + adoptionTrackedAPIs, + }; +} 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 new file mode 100644 index 0000000000000..7e9d5d8c70043 --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/collect_stats.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import { collectStats } from './collect_stats'; +import type { CliOptions, SetupProjectResult, BuildApiMapResult } from '../types'; + +// Mock dependencies +jest.mock('../../stats'); +jest.mock('../../count_eslint_disable'); +jest.mock('../../count_enzyme_imports'); + +const { collectApiStatsForPlugin } = jest.requireMock('../../stats'); +const { countEslintDisableLines } = jest.requireMock('../../count_eslint_disable'); +const { countEnzymeImports } = jest.requireMock('../../count_enzyme_imports'); + +describe('collectStats', () => { + let log: ToolingLog; + let transaction: any; + let setupResult: SetupProjectResult; + let apiMapResult: BuildApiMapResult; + + beforeEach(() => { + log = new ToolingLog({ + level: 'silent', + writeTo: process.stdout, + }); + + transaction = { + startSpan: jest.fn(() => ({ + end: jest.fn(), + })), + }; + + const mockPlugin = { + id: 'test-plugin', + directory: 'src/plugins/test', + isPlugin: true, + manifest: { + id: 'test-plugin', + owner: { name: 'test-team' }, + description: 'Test plugin', + serviceFolders: [], + }, + manifestPath: 'src/plugins/test/kibana.json', + }; + + setupResult = { + plugins: [mockPlugin], + pathsByPlugin: new Map([[mockPlugin, ['src/plugins/test/public/index.ts']]]), + project: {} as any, + }; + + apiMapResult = { + pluginApiMap: { + 'test-plugin': { + id: 'test-plugin', + client: [], + server: [], + common: [], + }, + }, + missingApiItems: {}, + referencedDeprecations: {}, + unreferencedDeprecations: {}, + adoptionTrackedAPIs: {}, + }; + + (collectApiStatsForPlugin as jest.Mock).mockReturnValue({ + apiCount: 0, + missingComments: [], + isAnyType: [], + noReferences: [], + missingExports: 0, + deprecatedAPIsReferencedCount: 0, + unreferencedDeprecatedApisCount: 0, + adoptionTrackedAPIs: [], + adoptionTrackedAPIsCount: 0, + adoptionTrackedAPIsUnreferencedCount: 0, + }); + + (countEslintDisableLines as jest.Mock).mockResolvedValue({ + eslintDisableLineCount: 0, + eslintDisableFileCount: 0, + }); + + (countEnzymeImports as jest.Mock).mockResolvedValue({ + enzymeImportCount: 0, + }); + }); + + it('collects stats for all plugins', async () => { + const options: CliOptions = { + collectReferences: false, + }; + + const result = await collectStats(setupResult, apiMapResult, log, transaction, options); + + expect(result).toBeDefined(); + expect(result['test-plugin']).toBeDefined(); + expect(result['test-plugin'].owner).toBeDefined(); + expect(result['test-plugin'].description).toBeDefined(); + expect(result['test-plugin'].isPlugin).toBe(true); + }); + + it('skips plugins when stats and pluginFilter are provided', async () => { + const options: CliOptions = { + collectReferences: false, + stats: ['any'], + pluginFilter: ['other-plugin'], + }; + + const result = await collectStats(setupResult, apiMapResult, log, transaction, options); + + expect(result['test-plugin']).toBeUndefined(); + }); + + it('calls countEslintDisableLines and countEnzymeImports', async () => { + const options: CliOptions = { + collectReferences: false, + }; + + await collectStats(setupResult, apiMapResult, log, transaction, options); + + expect(countEslintDisableLines).toHaveBeenCalled(); + expect(countEnzymeImports).toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-docs-utils/src/cli/tasks/collect_stats.ts b/packages/kbn-docs-utils/src/cli/tasks/collect_stats.ts new file mode 100644 index 0000000000000..2853e47323f89 --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/collect_stats.ts @@ -0,0 +1,79 @@ +/* + * 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 { Transaction } from 'elastic-apm-node'; +import type { ToolingLog } from '@kbn/tooling-log'; +import { collectApiStatsForPlugin } from '../../stats'; +import { countEslintDisableLines } from '../../count_eslint_disable'; +import { countEnzymeImports } from '../../count_enzyme_imports'; +import type { AllPluginStats, BuildApiMapResult, CliOptions, SetupProjectResult } from '../types'; + +/** + * Collects statistics for all plugins. + * + * This task: + * - Collects API statistics (missing comments, any types, etc.) + * - Counts ESLint disable lines + * - Counts Enzyme imports + * - Combines all stats into a single object per plugin + * + * @param setupResult - Result from setup_project task. + * @param apiMapResult - Result from build_api_map task. + * @param log - Tooling log instance. + * @param transaction - APM transaction for tracking. + * @param options - CLI options including stats and pluginFilter. + * @returns All plugin stats keyed by plugin ID. + */ +export async function collectStats( + setupResult: SetupProjectResult, + apiMapResult: BuildApiMapResult, + log: ToolingLog, + transaction: Transaction, + options: CliOptions +): Promise { + const { plugins, pathsByPlugin } = setupResult; + const { pluginApiMap, missingApiItems, referencedDeprecations, adoptionTrackedAPIs } = + apiMapResult; + + const allPluginStats: AllPluginStats = {}; + + for (const plugin of plugins) { + const id = plugin.id; + + if (options.stats && options.pluginFilter && !options.pluginFilter.includes(plugin.id)) { + continue; + } + + const spanApiStatsForPlugin = transaction.startSpan( + `build_api_docs.collectApiStatsForPlugin-${id}`, + 'stats' + ); + + const pluginApi = pluginApiMap[id]; + const paths = pathsByPlugin.get(plugin) ?? []; + + allPluginStats[id] = { + ...(await countEslintDisableLines(paths)), + ...(await countEnzymeImports(paths)), + ...collectApiStatsForPlugin( + pluginApi, + missingApiItems, + referencedDeprecations, + adoptionTrackedAPIs + ), + owner: plugin.manifest.owner, + description: plugin.manifest.description, + isPlugin: plugin.isPlugin, + }; + + spanApiStatsForPlugin?.end(); + } + + return allPluginStats; +} diff --git a/packages/kbn-docs-utils/src/cli/tasks/index.ts b/packages/kbn-docs-utils/src/cli/tasks/index.ts new file mode 100644 index 0000000000000..1e3d00b9a3013 --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/index.ts @@ -0,0 +1,14 @@ +/* + * 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". + */ + +export { setupProject } from './setup_project'; +export { buildApiMap } from './build_api_map'; +export { collectStats } from './collect_stats'; +export { writeDocs } from './write_docs'; +export { reportMetrics } from './report_metrics'; 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 new file mode 100644 index 0000000000000..31b0f371b5192 --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/report_metrics.test.ts @@ -0,0 +1,139 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import { reportMetrics } from './report_metrics'; +import type { CliOptions, SetupProjectResult, BuildApiMapResult, AllPluginStats } from '../types'; + +// Mock dependencies +jest.mock('@kbn/ci-stats-reporter', () => ({ + CiStatsReporter: { + fromEnv: jest.fn(() => ({ + metrics: jest.fn(), + })), + }, +})); + +import { CiStatsReporter } from '@kbn/ci-stats-reporter'; + +describe('reportMetrics', () => { + let log: ToolingLog; + let transaction: any; + let setupResult: SetupProjectResult; + let apiMapResult: BuildApiMapResult; + let allPluginStats: AllPluginStats; + let mockReporter: any; + + beforeEach(() => { + log = new ToolingLog({ + level: 'silent', + writeTo: process.stdout, + }); + + transaction = { + startSpan: jest.fn(() => ({ + end: jest.fn(), + })), + }; + + mockReporter = { + metrics: jest.fn(), + }; + + (CiStatsReporter.fromEnv as jest.Mock).mockReturnValue(mockReporter); + + const mockPlugin = { + id: 'test-plugin', + directory: 'src/plugins/test', + isPlugin: true, + manifest: { + id: 'test-plugin', + owner: { name: 'test-team' }, + serviceFolders: [], + }, + manifestPath: 'src/plugins/test/kibana.json', + }; + + setupResult = { + plugins: [mockPlugin], + pathsByPlugin: new Map(), + project: {} as any, + }; + + apiMapResult = { + pluginApiMap: {}, + missingApiItems: {}, + referencedDeprecations: {}, + unreferencedDeprecations: {}, + adoptionTrackedAPIs: {}, + }; + + allPluginStats = { + 'test-plugin': { + apiCount: 5, + missingComments: [], + isAnyType: [], + noReferences: [], + missingExports: 0, + deprecatedAPIsReferencedCount: 0, + unreferencedDeprecatedApisCount: 0, + adoptionTrackedAPIs: [], + adoptionTrackedAPIsCount: 0, + adoptionTrackedAPIsUnreferencedCount: 0, + owner: { name: 'test-team' }, + description: 'Test plugin', + isPlugin: true, + eslintDisableLineCount: 0, + eslintDisableFileCount: 0, + enzymeImportCount: 0, + }, + }; + }); + + it('reports metrics to CI stats reporter', () => { + const options: CliOptions = { + collectReferences: false, + }; + + reportMetrics(setupResult, apiMapResult, allPluginStats, log, transaction, options); + + expect(mockReporter.metrics).toHaveBeenCalled(); + expect(mockReporter.metrics.mock.calls[0][0]).toBeInstanceOf(Array); + expect(mockReporter.metrics.mock.calls[0][0].length).toBeGreaterThan(0); + }); + + it('includes all expected metric groups', () => { + const options: CliOptions = { + collectReferences: false, + }; + + reportMetrics(setupResult, apiMapResult, allPluginStats, log, transaction, options); + + const metrics = mockReporter.metrics.mock.calls[0][0]; + const groups = metrics.map((m: any) => m.group); + + expect(groups).toContain('API count'); + expect(groups).toContain('API count missing comments'); + expect(groups).toContain('API count with any type'); + expect(groups).toContain('ESLint disabled line counts'); + expect(groups).toContain('Enzyme imports'); + }); + + it('filters plugins based on pluginFilter', () => { + const options: CliOptions = { + collectReferences: false, + pluginFilter: ['other-plugin'], + }; + + reportMetrics(setupResult, apiMapResult, allPluginStats, log, transaction, options); + + // Should not report metrics for filtered-out plugins + expect(mockReporter.metrics).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/kbn-docs-utils/src/cli/tasks/report_metrics.ts b/packages/kbn-docs-utils/src/cli/tasks/report_metrics.ts new file mode 100644 index 0000000000000..fc3d467712439 --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/report_metrics.ts @@ -0,0 +1,230 @@ +/* + * 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 { 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)}`; +} + +/** + * Reports metrics to CI stats and logs validation results. + * + * This task: + * - Reports metrics to CI stats reporter + * - Logs validation results (any types, missing comments, missing exports) + * - Displays tables of issues when stats flags are enabled + * - Logs referenced deprecations and unused APIs when collecting references + * + * @param setupResult - Result from setup_project task. + * @param apiMapResult - Result from build_api_map task. + * @param allPluginStats - All collected plugin statistics. + * @param log - Tooling log instance. + * @param transaction - APM transaction for tracking. + * @param options - CLI options including stats, collectReferences, and pluginFilter. + */ +export function reportMetrics( + setupResult: SetupProjectResult, + apiMapResult: BuildApiMapResult, + allPluginStats: AllPluginStats, + log: ToolingLog, + transaction: Transaction, + options: CliOptions +): void { + const { plugins } = setupResult; + const { missingApiItems, referencedDeprecations } = apiMapResult; + const reporter = CiStatsReporter.fromEnv(log); + + 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; + } + + const id = plugin.id; + const pluginStats = allPluginStats[id]; + const pluginTeam = plugin.manifest.owner.name; + + const spanMetrics = transaction.startSpan( + `build_api_docs.collectApiStatsForPlugin-${id}`, + 'stats' + ); + + reporter.metrics([ + { + id, + meta: { pluginTeam }, + group: 'Unreferenced deprecated APIs', + value: referencedDeprecations[id] ? referencedDeprecations[id].length : 0, + }, + { + id, + meta: { pluginTeam }, + group: 'API count', + value: pluginStats.apiCount, + }, + { + id, + meta: { pluginTeam }, + group: 'API count missing comments', + value: pluginStats.missingComments.length, + }, + { + id, + meta: { pluginTeam }, + group: 'API count with any type', + value: pluginStats.isAnyType.length, + }, + { + id, + meta: { pluginTeam }, + group: 'Non-exported public API item count', + value: missingApiItems[id] ? Object.keys(missingApiItems[id]).length : 0, + }, + { + id, + meta: { pluginTeam }, + group: 'References to deprecated APIs', + value: pluginStats.deprecatedAPIsReferencedCount, + }, + { + id, + meta: { + pluginTeam, + // `meta` only allows primitives or string[] + // Also, each string is allowed to have a max length of 2056, + // so it's safer to stringify each element in the array over sending the entire array as stringified. + // My internal tests with 4 plugins using the same API gets to a length of 156 chars, + // so we should have enough room for tracking popular APIs. + // TODO: We can do a follow-up improvement to split the report if we find out we might hit the limit. + adoptionTrackedAPIs: pluginStats.adoptionTrackedAPIs.map((metric) => + JSON.stringify(metric) + ), + }, + group: 'Adoption-tracked APIs', + value: pluginStats.adoptionTrackedAPIsCount, + }, + { + id, + meta: { pluginTeam }, + group: 'Adoption-tracked APIs that are not used anywhere', + value: pluginStats.adoptionTrackedAPIsUnreferencedCount, + }, + { + id, + meta: { pluginTeam }, + group: 'ESLint disabled line counts', + value: pluginStats.eslintDisableLineCount, + }, + { + id, + meta: { pluginTeam }, + group: 'ESLint disabled in files', + value: pluginStats.eslintDisableFileCount, + }, + { + id, + meta: { pluginTeam }, + group: 'Total ESLint disabled count', + value: pluginStats.eslintDisableFileCount + pluginStats.eslintDisableLineCount, + }, + { + id, + meta: { pluginTeam }, + group: 'Enzyme imports', + value: pluginStats.enzymeImportCount, + }, + ]); + + if (options.collectReferences && options.pluginFilter?.includes(plugin.id)) { + if (referencedDeprecations[id] && pluginStats.deprecatedAPIsReferencedCount > 0) { + log.info(`${referencedDeprecations[id].length} deprecated APIs used`); + // eslint-disable-next-line no-console + console.table(referencedDeprecations[id]); + } else { + log.info(`No referenced deprecations for plugin ${plugin.id}`); + } + if (pluginStats.noReferences.length > 0) { + // eslint-disable-next-line no-console + console.table( + pluginStats.noReferences.map((d) => ({ + id: d.id, + link: getLink(d), + })) + ); + } else { + log.info(`No unused APIs for plugin ${plugin.id}`); + } + } + + if (options.stats) { + const passesAllChecks = + pluginStats.isAnyType.length === 0 && + pluginStats.missingComments.length === 0 && + pluginStats.deprecatedAPIsReferencedCount === 0 && + (!missingApiItems[id] || Object.keys(missingApiItems[id]).length === 0); + + log.info(`--- Plugin '${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( + pluginStats.isAnyType.map((d) => ({ + id: d.id, + link: getLink(d), + })) + ); + } + + log.info(`${pluginStats.missingComments.length} API items missing comments`); + if (options.stats.includes('comments')) { + // eslint-disable-next-line no-console + console.table( + pluginStats.missingComments.map((d) => ({ + id: d.id, + link: getLink(d), + })) + ); + } + + 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(', '), + })) + ); + } + } + } + } + + spanMetrics?.end(); + } +} 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 new file mode 100644 index 0000000000000..a65a3a2bec0c3 --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/setup_project.test.ts @@ -0,0 +1,158 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import { setupProject } from './setup_project'; +import type { CliContext, CliOptions } from '../types'; + +// Mock dependencies - order matters: mock get_all_doc_file_ids first to prevent globby from loading +jest.mock('../../mdx/get_all_doc_file_ids', () => ({ + getAllDocFileIds: jest.fn(() => Promise.resolve([])), +})); +// Mock fs before @kbn/repo-info since it uses fs internally +// Use jest.requireActual to preserve all fs functions that globby needs +jest.mock('fs', () => { + const actualFs = jest.requireActual('fs'); + return { + ...actualFs, + existsSync: jest.fn(() => false), + readFileSync: jest.fn((path: string) => { + // Return valid JSON for package.json paths + if (path.includes('package.json')) { + return JSON.stringify({ name: 'kibana', version: '1.0.0' }); + } + return '{}'; + }), + realpathSync: jest.fn((path: string) => path), + }; +}); +jest.mock('@kbn/repo-info', () => ({ + REPO_ROOT: '/mock/repo/root', +})); +jest.mock('../../find_plugins'); +jest.mock('../../get_paths_by_package'); +jest.mock('fs/promises', () => ({ + rm: jest.fn(() => Promise.resolve()), + mkdir: jest.fn(() => Promise.resolve()), +})); +// Mock ts-morph to avoid Node.js internals access issues in Jest +jest.mock('ts-morph', () => { + const mockProject = { + addSourceFilesAtPaths: jest.fn(), + resolveSourceFileDependencies: jest.fn(), + }; + return { + Project: jest.fn(() => mockProject), + }; +}); + +import { findPlugins } from '../../find_plugins'; +import { getPathsByPackage } from '../../get_paths_by_package'; +import { getAllDocFileIds } from '../../mdx/get_all_doc_file_ids'; + +describe('setupProject', () => { + let log: ToolingLog; + let transaction: any; + let context: CliContext; + + beforeEach(() => { + log = new ToolingLog({ + level: 'silent', + writeTo: process.stdout, + }); + + transaction = { + startSpan: jest.fn(() => ({ + end: jest.fn(), + })), + }; + + context = { + log, + transaction, + outputFolder: Path.resolve(__dirname, '../../../../api_docs'), + }; + + (findPlugins as jest.Mock).mockReturnValue([]); + (getPathsByPackage as jest.Mock).mockResolvedValue(new Map()); + (getAllDocFileIds as jest.Mock).mockResolvedValue([]); + }); + + it('returns setup result with plugins, paths, and project', async () => { + const mockPlugins = [ + { + id: 'test-plugin', + directory: Path.resolve(__dirname, '../../../../src/plugins/test'), + isPlugin: true, + manifest: { + id: 'test-plugin', + owner: { name: 'test-team' }, + serviceFolders: [], + }, + manifestPath: Path.resolve(__dirname, '../../../../src/plugins/test/kibana.json'), + }, + ]; + + (findPlugins as jest.Mock).mockReturnValue(mockPlugins); + (getPathsByPackage as jest.Mock).mockResolvedValue( + new Map([[mockPlugins[0], ['src/plugins/test/public/index.ts']]]) + ); + + const options: CliOptions = { + collectReferences: false, + }; + + const result = await setupProject(context, options); + + expect(result.plugins).toBeDefined(); + expect(result.pathsByPlugin).toBeDefined(); + expect(result.project).toBeDefined(); + }); + + it('collects initial doc IDs when output folder exists and no plugin filter', async () => { + const Fs = jest.requireMock('fs'); + Fs.existsSync.mockReturnValue(true); + (getAllDocFileIds as jest.Mock).mockResolvedValue(['doc1', 'doc2']); + + const options: CliOptions = { + collectReferences: false, + }; + + const result = await setupProject(context, options); + + expect(result.initialDocIds).toEqual(['doc1', 'doc2']); + }); + + it('does not collect initial doc IDs when plugin filter is provided', async () => { + const Fs = jest.requireMock('fs'); + Fs.existsSync.mockReturnValue(true); + + const options: CliOptions = { + collectReferences: false, + pluginFilter: ['test-plugin'], + }; + + const result = await setupProject(context, options); + + expect(result.initialDocIds).toBeUndefined(); + }); + + it('validates plugin filter and throws error if plugins not found', async () => { + (findPlugins as jest.Mock).mockReturnValue([]); + + const options: CliOptions = { + collectReferences: false, + stats: ['any'], + pluginFilter: ['nonexistent-plugin'], + }; + + await expect(setupProject(context, options)).rejects.toThrow('expected --plugin was not found'); + }); +}); diff --git a/packages/kbn-docs-utils/src/cli/tasks/setup_project.ts b/packages/kbn-docs-utils/src/cli/tasks/setup_project.ts new file mode 100644 index 0000000000000..2cbab7ec341ce --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/setup_project.ts @@ -0,0 +1,126 @@ +/* + * 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 Fsp from 'fs/promises'; +import { Project } from 'ts-morph'; +import { REPO_ROOT } from '@kbn/repo-info'; +import { createFlagError } from '@kbn/dev-cli-errors'; +import { findPlugins } from '../../find_plugins'; +import { getPathsByPackage } from '../../get_paths_by_package'; +import { getAllDocFileIds } from '../../mdx/get_all_doc_file_ids'; +import type { CliContext, CliOptions, SetupProjectResult } from '../types'; + +/** + * Creates a TypeScript project for parsing and analyzing TypeScript files. + * + * @param repoPath - Root path of the repository. + * @param overridePath - Optional override path for a specific plugin directory. + * @returns Configured TypeScript project. + */ +function getTsProject(repoPath: string, overridePath?: string): Project { + const xpackTsConfig = !overridePath + ? `${repoPath}/tsconfig.json` + : `${overridePath}/tsconfig.json`; + + const project = new Project({ + tsConfigFilePath: xpackTsConfig, + // We'll use the files added below instead. + skipAddingFilesFromTsConfig: true, + }); + + if (!overridePath) { + 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']); + project.addSourceFilesAtPaths([`${repoPath}/x-pack/solutions/**/*.ts`, '!**/*.d.ts']); + project.addSourceFilesAtPaths([`${repoPath}/src/plugins/**/*.ts`, '!**/*.d.ts']); + project.addSourceFilesAtPaths([`${repoPath}/src/platform/**/*.ts`, '!**/*.d.ts']); + project.addSourceFilesAtPaths([`${repoPath}/src/core/packages/**/*.ts`, '!**/*.d.ts']); + project.addSourceFilesAtPaths([`${repoPath}/packages/**/*.ts`, '!**/*.d.ts']); + } else { + project.addSourceFilesAtPaths([`${overridePath}/**/*.ts`, '!**/*.d.ts']); + } + project.resolveSourceFileDependencies(); + return project; +} + +/** + * Sets up the project for API documentation generation. + * + * This task handles: + * - Validating plugin filters + * - Discovering plugins and packages + * - Resolving file paths by package + * - Creating the TypeScript project + * - Setting up the output folder + * - Collecting initial document IDs for cleanup + * + * @param context - CLI context with log, transaction, and output folder. + * @param options - Parsed CLI options. + * @returns Setup result with plugins, paths, and TypeScript project. + * @throws {Error} If plugin filter validation fails. + */ +export async function setupProject( + context: CliContext, + options: CliOptions +): Promise { + const { transaction, outputFolder } = context; + + const spanInitialDocIds = transaction.startSpan('build_api_docs.initialDocIds', 'setup'); + const initialDocIds = + !options.pluginFilter && Fs.existsSync(outputFolder) + ? await getAllDocFileIds(outputFolder) + : undefined; + spanInitialDocIds?.end(); + + const spanPlugins = transaction.startSpan('build_api_docs.findPlugins', 'setup'); + const plugins = findPlugins( + options.stats && options.pluginFilter ? options.pluginFilter : undefined + ); + + if ( + options.stats && + Array.isArray(options.pluginFilter) && + options.pluginFilter.length !== plugins.length + ) { + throw createFlagError('expected --plugin was not found'); + } + spanPlugins?.end(); + + const spanPathsByPackage = transaction.startSpan('build_api_docs.getPathsByPackage', 'setup'); + const pathsByPlugin = await getPathsByPackage(plugins); + spanPathsByPackage?.end(); + + const spanProject = transaction.startSpan('build_api_docs.getTsProject', 'setup'); + const project = getTsProject( + REPO_ROOT, + options.stats && options.pluginFilter && plugins.length === 1 ? plugins[0].directory : undefined + ); + spanProject?.end(); + + const spanFolders = transaction.startSpan('build_api_docs.check-folders', 'setup'); + // if the output folder already exists, and we don't have a plugin filter, delete all the files in the output folder + if (Fs.existsSync(outputFolder) && !options.pluginFilter) { + await Fsp.rm(outputFolder, { recursive: true }); + } + + // if the output folder doesn't exist, create it + if (!Fs.existsSync(outputFolder)) { + await Fsp.mkdir(outputFolder, { recursive: true }); + } + spanFolders?.end(); + + return { + plugins, + 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 new file mode 100644 index 0000000000000..5d64cb1011037 --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/write_docs.test.ts @@ -0,0 +1,166 @@ +/* + * 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 { ToolingLog } from '@kbn/tooling-log'; +import { writeDocs } from './write_docs'; +import type { + CliContext, + CliOptions, + SetupProjectResult, + BuildApiMapResult, + AllPluginStats, +} from '../types'; + +// Mock dependencies +jest.mock('../../mdx/write_plugin_mdx_docs'); +jest.mock('../../mdx/write_deprecations_doc_by_api'); +jest.mock('../../mdx/write_deprecations_doc_by_plugin'); +jest.mock('../../mdx/write_plugin_directory_doc'); +jest.mock('../../mdx/write_deprecations_due_by_team'); +jest.mock('../../trim_deleted_docs_from_nav'); + +import { writePluginDocs } from '../../mdx/write_plugin_mdx_docs'; +import { writeDeprecationDocByApi } from '../../mdx/write_deprecations_doc_by_api'; +import { writeDeprecationDocByPlugin } from '../../mdx/write_deprecations_doc_by_plugin'; +import { writePluginDirectoryDoc } from '../../mdx/write_plugin_directory_doc'; +import { writeDeprecationDueByTeam } from '../../mdx/write_deprecations_due_by_team'; +import { trimDeletedDocsFromNav } from '../../trim_deleted_docs_from_nav'; + +describe('writeDocs', () => { + let context: CliContext; + let setupResult: SetupProjectResult; + let apiMapResult: BuildApiMapResult; + let allPluginStats: AllPluginStats; + + beforeEach(() => { + const mockPlugin = { + id: 'test-plugin', + directory: 'src/plugins/test', + isPlugin: true, + manifest: { + id: 'test-plugin', + owner: { name: 'test-team' }, + serviceFolders: [], + }, + manifestPath: 'src/plugins/test/kibana.json', + }; + + context = { + log: new ToolingLog({ + level: 'silent', + writeTo: process.stdout, + }), + transaction: { + startSpan: jest.fn(() => ({ + end: jest.fn(), + })), + } as any, + outputFolder: '/tmp/api_docs', + }; + + setupResult = { + plugins: [mockPlugin], + pathsByPlugin: new Map(), + project: {} as any, + initialDocIds: ['doc1', 'doc2'], + }; + + apiMapResult = { + pluginApiMap: { + 'test-plugin': { + id: 'test-plugin', + client: [], + server: [], + common: [], + }, + }, + missingApiItems: {}, + referencedDeprecations: {}, + unreferencedDeprecations: {}, + adoptionTrackedAPIs: {}, + }; + + allPluginStats = { + 'test-plugin': { + apiCount: 5, + missingComments: [], + isAnyType: [], + noReferences: [], + missingExports: 0, + deprecatedAPIsReferencedCount: 0, + unreferencedDeprecatedApisCount: 0, + adoptionTrackedAPIs: [], + adoptionTrackedAPIsCount: 0, + adoptionTrackedAPIsUnreferencedCount: 0, + owner: { name: 'test-team' }, + description: 'Test plugin', + isPlugin: true, + eslintDisableLineCount: 0, + eslintDisableFileCount: 0, + enzymeImportCount: 0, + }, + }; + + // Clear all mocks before each test + jest.clearAllMocks(); + + // Set up mock implementations + (writePluginDocs as jest.Mock).mockResolvedValue(undefined); + (writeDeprecationDocByApi as jest.Mock).mockResolvedValue(undefined); + (writeDeprecationDocByPlugin as jest.Mock).mockResolvedValue(undefined); + (writePluginDirectoryDoc as jest.Mock).mockResolvedValue(undefined); + (writeDeprecationDueByTeam as jest.Mock).mockResolvedValue(undefined); + (trimDeletedDocsFromNav as jest.Mock).mockResolvedValue(undefined); + }); + + it('skips writing plugin directory doc when stats is provided', async () => { + const options: CliOptions = { + collectReferences: false, + stats: ['any'], + }; + + await writeDocs(context, setupResult, apiMapResult, allPluginStats, options); + + expect(writePluginDirectoryDoc).not.toHaveBeenCalled(); + }); + + it('writes plugin directory doc when stats is not provided', async () => { + const options: CliOptions = { + collectReferences: false, + }; + + await writeDocs(context, setupResult, apiMapResult, allPluginStats, options); + + expect(writePluginDirectoryDoc).toHaveBeenCalled(); + }); + + it('writes plugin docs when stats is not provided', async () => { + const options: CliOptions = { + collectReferences: false, + }; + + await writeDocs(context, setupResult, apiMapResult, allPluginStats, options); + + expect(writePluginDocs).toHaveBeenCalled(); + }); + + it('trims deleted docs from nav when initialDocIds are provided', async () => { + const options: CliOptions = { + collectReferences: false, + }; + + await writeDocs(context, setupResult, apiMapResult, allPluginStats, options); + + expect(trimDeletedDocsFromNav).toHaveBeenCalledWith( + context.log, + setupResult.initialDocIds, + context.outputFolder + ); + }); +}); diff --git a/packages/kbn-docs-utils/src/cli/tasks/write_docs.ts b/packages/kbn-docs-utils/src/cli/tasks/write_docs.ts new file mode 100644 index 0000000000000..0a6e6388d7c7d --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/tasks/write_docs.ts @@ -0,0 +1,127 @@ +/* + * 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 { writePluginDocs } from '../../mdx/write_plugin_mdx_docs'; +import { writeDeprecationDocByApi } from '../../mdx/write_deprecations_doc_by_api'; +import { writeDeprecationDocByPlugin } from '../../mdx/write_deprecations_doc_by_plugin'; +import { writePluginDirectoryDoc } from '../../mdx/write_plugin_directory_doc'; +import { writeDeprecationDueByTeam } from '../../mdx/write_deprecations_due_by_team'; +import { trimDeletedDocsFromNav } from '../../trim_deleted_docs_from_nav'; +import type { + AllPluginStats, + BuildApiMapResult, + CliContext, + CliOptions, + SetupProjectResult, +} from '../types'; + +/** + * Writes all documentation files. + * + * This task: + * - Writes plugin API documentation + * - Writes deprecation documentation (by API, by plugin, by team) + * - Writes plugin directory documentation + * - Trims deleted docs from navigation + * + * @param context - CLI context with log, transaction, and output folder. + * @param setupResult - Result from setup_project task. + * @param apiMapResult - Result from build_api_map task. + * @param allPluginStats - All collected plugin statistics. + * @param options - CLI options including pluginFilter. + */ +export async function writeDocs( + context: CliContext, + setupResult: SetupProjectResult, + apiMapResult: BuildApiMapResult, + allPluginStats: AllPluginStats, + options: CliOptions +): Promise { + const { log, transaction, outputFolder } = context; + const { initialDocIds } = setupResult; + const { plugins } = setupResult; + const { pluginApiMap, referencedDeprecations, unreferencedDeprecations } = apiMapResult; + + if (!options.stats) { + const spanWritePluginDirectoryDoc = transaction.startSpan( + 'build_api_docs.writePluginDirectoryDoc', + 'write' + ); + + await writePluginDirectoryDoc(outputFolder, pluginApiMap, allPluginStats, log); + + spanWritePluginDirectoryDoc?.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; + } + + const id = plugin.id; + const pluginApi = pluginApiMap[id]; + const pluginStats = allPluginStats[id]; + + if (!options.stats) { + if (pluginStats.apiCount > 0) { + log.info(`Writing public API doc for plugin ${pluginApi.id}.`); + + const spanWritePluginDocs = transaction.startSpan( + 'build_api_docs.writePluginDocs', + 'write' + ); + + await writePluginDocs(outputFolder, { doc: pluginApi, plugin, pluginStats, log }); + + spanWritePluginDocs?.end(); + } else { + log.info(`Plugin ${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(); + } + } + + if (initialDocIds) { + await trimDeletedDocsFromNav(log, initialDocIds, outputFolder); + } +} diff --git a/packages/kbn-docs-utils/src/cli/types.ts b/packages/kbn-docs-utils/src/cli/types.ts new file mode 100644 index 0000000000000..7146e1b736a9b --- /dev/null +++ b/packages/kbn-docs-utils/src/cli/types.ts @@ -0,0 +1,104 @@ +/* + * 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 { Transaction } from 'elastic-apm-node'; +import type { Project } from 'ts-morph'; +import type { ToolingLog } from '@kbn/tooling-log'; +import type { + PluginOrPackage, + PluginApi, + MissingApiItemMap, + ReferencedDeprecationsByPlugin, + UnreferencedDeprecationsByPlugin, + AdoptionTrackedAPIsByPlugin, + ApiStats, + PluginMetaInfo, +} from '../types'; +import type { EslintDisableCounts } from '../count_eslint_disable'; +import type { EnzymeImportCounts } from '../count_enzyme_imports'; + +/** + * Parsed CLI flags from the command line. + */ +export interface CliFlags { + /** Whether to collect references for API items. */ + references?: boolean; + /** Stats flags: 'any', 'comments', and/or 'exports'. */ + stats?: string | string[]; + /** Plugin filter: single plugin ID or array of plugin IDs. */ + plugin?: string | string[]; +} + +/** + * Validated and normalized CLI options. + */ +export interface CliOptions { + /** Whether to collect references for API items. */ + collectReferences: boolean; + /** Stats flags to display. */ + stats?: string[]; + /** Plugin filter IDs. */ + pluginFilter?: string[]; +} + +/** + * Context shared across CLI tasks. + */ +export interface CliContext { + /** Tooling log instance. */ + log: ToolingLog; + /** APM transaction for tracking. */ + transaction: Transaction; + /** Output folder for generated documentation. */ + outputFolder: string; + /** Initial document IDs (for cleanup). */ + initialDocIds?: string[]; +} + +/** + * Result from setup_project task. + */ +export interface SetupProjectResult { + /** Discovered plugins and packages. */ + plugins: PluginOrPackage[]; + /** File paths grouped by package. */ + pathsByPlugin: Map; + /** TypeScript project instance. */ + project: Project; + /** Initial document IDs for cleanup (if output folder existed). */ + initialDocIds?: string[]; +} + +/** + * Result from build_api_map task. + */ +export interface BuildApiMapResult { + /** Plugin API map. */ + pluginApiMap: { [key: string]: PluginApi }; + /** Missing API items. */ + missingApiItems: MissingApiItemMap; + /** Referenced deprecations. */ + referencedDeprecations: ReferencedDeprecationsByPlugin; + /** Unreferenced deprecations. */ + unreferencedDeprecations: UnreferencedDeprecationsByPlugin; + /** Adoption-tracked APIs. */ + adoptionTrackedAPIs: AdoptionTrackedAPIsByPlugin; +} + +/** + * Combined plugin stats including API stats, ESLint counts, and Enzyme counts. + */ +export type PluginStats = PluginMetaInfo & ApiStats & EslintDisableCounts & EnzymeImportCounts; + +/** + * All plugin stats keyed by plugin ID. + */ +export interface AllPluginStats { + [key: string]: PluginStats; +}