From e835d8f47a4964856d030c6b4107bf3fa4575b72 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Wed, 17 Jan 2024 17:19:41 -0500 Subject: [PATCH] [dx] Dependency check script for plugins (#171483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Dealing with circular dependencies between plugins has become a sharp pain point for anyone developing plugins in Kibana. ### Providing dependencies to a plugin First, a plugin defines its dependencies in its `kibana.jsonc` file as one of three types: - `required` - the dependency must be present and enabled -- will be guaranteed in the lifecycle - `optional` - the dependency can be missing or disabled -- will be `undefined` in the lifecycle - `requiredBundle` - the dependency is required as static code only -- will not be present in the lifecycle Missing or circular dependencies are detected by the Kibana platform when it starts. ### Providing dependencies in code Our plugins are written in and type-checked by Typescript. As such, each plugin needs to maintain Typescript types defining what the platform is providing. This is done manually, and there is no enforcement mechanism between that and the plugin Typescript types. If these dependency definitions are inconsistent or stale, it can lead to host of issues: - optional plugins typed as required that are disabled crash the plugin at runtime; - plugins that are no longer used still included in dependency checks; - plugins marked as required or optional that are actually required bundles. - etc. ### Dependencies with side-effects One of the interesting things that has come out of this has been identifying plugins that provide dependent logic through side-effects, rather than lifecycles. As an example, `licensing` provides a lifecycle contracts, but also a [route handler context](https://github.com/elastic/kibana/blob/main/x-pack/plugins/licensing/server/licensing_route_handler_context.ts) as middleware for a dependent plugin. Unfortunately, while this dependency can be stated as `required` in a dependent plugin's `kibana.jsonc`, the fact that this is a side-effect makes it incredible difficult to understand the dependency without searching the code. Screenshot 2023-12-13 at 10 08 00 AM So the side-effect is more or less hidden from developers. This is likely why we see other plugins using the lifecycle [logic](https://github.com/elastic/kibana/blob/main/src/plugins/maps_ems/public/kibana_services.ts#L33-L37), or copy-pasting licensing check code [[1](https://github.com/elastic/kibana/blob/main/x-pack/plugins/actions/server/lib/license_state.ts), [2](https://github.com/elastic/kibana/blob/main/x-pack/plugins/alerting/server/lib/license_state.ts)], or relying on the route context side-effect. ## Proposed (initial) solution This script is an initial attempt to both identify these problems and surface a plugin's dependencies in a useful way. In addition, the script will warn if the classes aren't typed well, not typed at all, or even don't extend the `core` `Plugin` class. Screenshot 2023-12-13 at 12 37 25 AM Screenshot 2023-12-13 at 12 38 07 AM Screenshot 2023-12-13 at 12 38 35 AM For side-effects, identifying them is key, and then refactoring the plugins to provide appropriate logic in the `start` or `setup` contracts. ## Next steps - [x] refine the logic - [ ] write tests - [ ] add `--fix` option I'm also considering (in another PR), outputting a consistent type definition file-- perhaps `kibana.d.ts`-- to the plugin from which the implementing classes could `Omit<>` or `Pick<>` the relevant contracts. --- .github/CODEOWNERS | 1 + package.json | 3 +- packages/kbn-docs-utils/index.ts | 2 + packages/kbn-docs-utils/src/find_plugins.ts | 28 +- packages/kbn-docs-utils/src/types.ts | 3 + packages/kbn-plugin-check/.eslintrc.json | 5 + packages/kbn-plugin-check/README.md | 17 + packages/kbn-plugin-check/const.ts | 34 ++ .../dependencies/create_table.ts | 217 +++++++++++++ .../dependencies/display_dependency_check.ts | 45 +++ .../dependencies/get_dependency_summary.ts | 123 ++++++++ .../dependencies/get_plugin_info.ts | 293 ++++++++++++++++++ .../kbn-plugin-check/dependencies/index.ts | 47 +++ .../dependencies/table_borders.ts | 71 +++++ packages/kbn-plugin-check/dependents.ts | 63 ++++ packages/kbn-plugin-check/index.ts | 56 ++++ packages/kbn-plugin-check/jest.config.js | 13 + packages/kbn-plugin-check/kibana.jsonc | 5 + .../kbn-plugin-check/lib/get_all_plugins.ts | 20 ++ packages/kbn-plugin-check/lib/get_plugin.ts | 19 ++ .../lib/get_plugin_classes.ts | 46 +++ packages/kbn-plugin-check/lib/index.ts | 11 + packages/kbn-plugin-check/package.json | 6 + packages/kbn-plugin-check/rank.ts | 116 +++++++ packages/kbn-plugin-check/tsconfig.json | 21 ++ packages/kbn-plugin-check/types.ts | 119 +++++++ scripts/plugin_check.js | 10 + tsconfig.base.json | 2 + .../scripts/infer_route_return_types/index.ts | 6 +- yarn.lock | 30 +- 30 files changed, 1414 insertions(+), 18 deletions(-) create mode 100644 packages/kbn-plugin-check/.eslintrc.json create mode 100644 packages/kbn-plugin-check/README.md create mode 100644 packages/kbn-plugin-check/const.ts create mode 100644 packages/kbn-plugin-check/dependencies/create_table.ts create mode 100644 packages/kbn-plugin-check/dependencies/display_dependency_check.ts create mode 100644 packages/kbn-plugin-check/dependencies/get_dependency_summary.ts create mode 100644 packages/kbn-plugin-check/dependencies/get_plugin_info.ts create mode 100644 packages/kbn-plugin-check/dependencies/index.ts create mode 100644 packages/kbn-plugin-check/dependencies/table_borders.ts create mode 100644 packages/kbn-plugin-check/dependents.ts create mode 100644 packages/kbn-plugin-check/index.ts create mode 100644 packages/kbn-plugin-check/jest.config.js create mode 100644 packages/kbn-plugin-check/kibana.jsonc create mode 100644 packages/kbn-plugin-check/lib/get_all_plugins.ts create mode 100644 packages/kbn-plugin-check/lib/get_plugin.ts create mode 100644 packages/kbn-plugin-check/lib/get_plugin_classes.ts create mode 100644 packages/kbn-plugin-check/lib/index.ts create mode 100644 packages/kbn-plugin-check/package.json create mode 100644 packages/kbn-plugin-check/rank.ts create mode 100644 packages/kbn-plugin-check/tsconfig.json create mode 100644 packages/kbn-plugin-check/types.ts create mode 100644 scripts/plugin_check.js diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c9b48ed8a2847..d9449dd6b4105 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -587,6 +587,7 @@ packages/kbn-peggy @elastic/kibana-operations packages/kbn-peggy-loader @elastic/kibana-operations packages/kbn-performance-testing-dataset-extractor @elastic/kibana-performance-testing packages/kbn-picomatcher @elastic/kibana-operations +packages/kbn-plugin-check @elastic/appex-sharedux packages/kbn-plugin-generator @elastic/kibana-operations packages/kbn-plugin-helpers @elastic/kibana-operations examples/portable_dashboards_example @elastic/kibana-presentation diff --git a/package.json b/package.json index 0442655d230c0..38786cafb6619 100644 --- a/package.json +++ b/package.json @@ -594,6 +594,7 @@ "@kbn/paertial-results-example-plugin": "link:examples/partial_results_example", "@kbn/painless-lab-plugin": "link:x-pack/plugins/painless_lab", "@kbn/panel-loader": "link:packages/kbn-panel-loader", + "@kbn/plugin-check": "link:packages/kbn-plugin-check", "@kbn/portable-dashboards-example": "link:examples/portable_dashboards_example", "@kbn/preboot-example-plugin": "link:examples/preboot_example", "@kbn/presentation-containers": "link:packages/presentation/presentation_containers", @@ -1659,7 +1660,7 @@ "terser-webpack-plugin": "^4.2.3", "tough-cookie": "^4.1.3", "tree-kill": "^1.2.2", - "ts-morph": "^13.0.2", + "ts-morph": "^15.1.0", "tsd": "^0.20.0", "typescript": "4.7.4", "url-loader": "^2.2.0", diff --git a/packages/kbn-docs-utils/index.ts b/packages/kbn-docs-utils/index.ts index 2df2b1f807ba9..111234b0044c1 100644 --- a/packages/kbn-docs-utils/index.ts +++ b/packages/kbn-docs-utils/index.ts @@ -7,3 +7,5 @@ */ export { runBuildApiDocsCli } from './src'; + +export { findPlugins, findTeamPlugins } from './src/find_plugins'; diff --git a/packages/kbn-docs-utils/src/find_plugins.ts b/packages/kbn-docs-utils/src/find_plugins.ts index c77816ba3b6c8..8fbe47ad90d02 100644 --- a/packages/kbn-docs-utils/src/find_plugins.ts +++ b/packages/kbn-docs-utils/src/find_plugins.ts @@ -33,7 +33,7 @@ function toApiScope(pkg: Package): ApiScope { } function toPluginOrPackage(pkg: Package): PluginOrPackage { - return { + const result = { id: pkg.isPlugin() ? pkg.manifest.plugin.id : pkg.manifest.id, directory: Path.resolve(REPO_ROOT, pkg.normalizedRepoRelativeDir), manifestPath: Path.resolve(REPO_ROOT, pkg.normalizedRepoRelativeDir, 'kibana.jsonc'), @@ -50,6 +50,20 @@ function toPluginOrPackage(pkg: Package): PluginOrPackage { }, scope: toApiScope(pkg), }; + + if (pkg.isPlugin()) { + return { + ...result, + manifest: { + ...result.manifest, + requiredPlugins: pkg.manifest.plugin.requiredPlugins || [], + optionalPlugins: pkg.manifest.plugin.optionalPlugins || [], + requiredBundles: pkg.manifest.plugin.requiredBundles || [], + }, + }; + } + + return result; } export function findPlugins(pluginOrPackageFilter?: string[]): PluginOrPackage[] { @@ -78,6 +92,18 @@ export function findPlugins(pluginOrPackageFilter?: string[]): PluginOrPackage[] } } +export function findTeamPlugins(team: string): PluginOrPackage[] { + const packages = getPackages(REPO_ROOT); + const plugins = packages.filter( + getPluginPackagesFilter({ + examples: false, + testPlugins: false, + }) + ); + + return [...plugins.filter((p) => p.manifest.owner.includes(team)).map(toPluginOrPackage)]; +} + /** * Helper to find packages. */ diff --git a/packages/kbn-docs-utils/src/types.ts b/packages/kbn-docs-utils/src/types.ts index 40faadb8c41f1..1d91d78a7c734 100644 --- a/packages/kbn-docs-utils/src/types.ts +++ b/packages/kbn-docs-utils/src/types.ts @@ -14,6 +14,9 @@ export interface PluginOrPackage { description?: string; owner: { name: string; githubTeam?: string }; serviceFolders: readonly string[]; + requiredBundles?: readonly string[]; + requiredPlugins?: readonly string[]; + optionalPlugins?: readonly string[]; }; isPlugin: boolean; directory: string; diff --git a/packages/kbn-plugin-check/.eslintrc.json b/packages/kbn-plugin-check/.eslintrc.json new file mode 100644 index 0000000000000..2f2f707c490b9 --- /dev/null +++ b/packages/kbn-plugin-check/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "import/no-extraneous-dependencies": "off" + } +} diff --git a/packages/kbn-plugin-check/README.md b/packages/kbn-plugin-check/README.md new file mode 100644 index 0000000000000..94803493375f1 --- /dev/null +++ b/packages/kbn-plugin-check/README.md @@ -0,0 +1,17 @@ +# @kbn/plugin-check + +This package contains a CLI to detect inconsistencies between the manifest and Typescript types of a Kibana plugin. Future work will include automatically fixing these inconsistencies. + +## Usage + +To check a single plugin, run the following command from the root of the Kibana repo: + +```sh +node scripts/plugin_check --plugin pluginName +``` + +To check all plugins owned by a team, run the following: + +```sh +node scripts/plugin_check --team @elastic/team_name +``` diff --git a/packages/kbn-plugin-check/const.ts b/packages/kbn-plugin-check/const.ts new file mode 100644 index 0000000000000..974de652ce4bf --- /dev/null +++ b/packages/kbn-plugin-check/const.ts @@ -0,0 +1,34 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +/** Type types of plugin classes within a single plugin. */ +export const PLUGIN_LAYERS = ['server', 'client'] as const; + +/** The lifecycles a plugin class implements. */ +export const PLUGIN_LIFECYCLES = ['setup', 'start'] as const; + +/** An enum representing the dependency requirements for a plugin. */ +export const PLUGIN_REQUIREMENTS = ['required', 'optional'] as const; + +/** An enum representing the manifest requirements for a plugin. */ +export const MANIFEST_REQUIREMENTS = ['required', 'optional', 'bundle'] as const; + +/** The state of a particular dependency as it relates to the plugin manifest. */ +export const MANIFEST_STATES = ['required', 'optional', 'bundle', 'missing'] as const; + +/** + * The state of a particular dependency as it relates to a plugin class. Includes states where the + * plugin is missing properties to determine that state. + */ +export const PLUGIN_STATES = ['required', 'optional', 'missing', 'no class', 'unknown'] as const; + +/** The state of the dependency for the entire plugin. */ +export const DEPENDENCY_STATES = ['required', 'optional', 'mismatch'] as const; + +/** An enum representing how the dependency status was derived from the plugin class. */ +export const SOURCE_OF_TYPE = ['implements', 'method', 'none'] as const; diff --git a/packages/kbn-plugin-check/dependencies/create_table.ts b/packages/kbn-plugin-check/dependencies/create_table.ts new file mode 100644 index 0000000000000..ed282d05858d7 --- /dev/null +++ b/packages/kbn-plugin-check/dependencies/create_table.ts @@ -0,0 +1,217 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import Table, { Table as TableType } from 'cli-table3'; + +import colors from 'colors/safe'; + +import { ToolingLog } from '@kbn/tooling-log'; + +import { PluginLayer, PluginLifecycle, PluginInfo, PluginStatuses, PluginState } from '../types'; +import { PLUGIN_LAYERS, PLUGIN_LIFECYCLES } from '../const'; +import { borders } from './table_borders'; + +// A lot of this logic is brute-force and ugly. It's a quick and dirty way to get the +// proof-of-concept working. +export const createTable = ( + pluginInfo: PluginInfo, + statuses: PluginStatuses, + _log: ToolingLog +): TableType => { + const table = new Table({ + colAligns: ['left', 'center', 'center', 'center', 'center', 'center', 'center'], + style: { + 'padding-left': 2, + 'padding-right': 2, + }, + chars: borders.table, + }); + + const noDependencies = Object.keys(statuses).length === 0; + const noServerPlugin = pluginInfo.classes.server === null; + const noClientPlugin = pluginInfo.classes.client === null; + const noPlugins = noServerPlugin && noClientPlugin; + + if (noDependencies || noPlugins) { + table.push([pluginInfo.name]); + + if (noDependencies) { + table.push(['Plugin has no dependencies.']); + } + + if (noPlugins) + table.push([ + 'Plugin has no client or server implementation.\nIt should be migrated to a package, or only be a requiredBundle.', + ]); + + return table; + } + + /** + * Build and format the header cell for the plugin lifecycle column. + */ + const getLifecycleColumnHeader = (layer: PluginLayer, lifecycle: PluginLifecycle) => + Object.entries(statuses).some( + ([_name, statusObj]) => statusObj[layer][lifecycle].source === 'none' + ) + ? colors.red(lifecycle.toUpperCase()) + : lifecycle.toUpperCase(); + + /** + * Build and format the header cell for the plugin layer column. + */ + const getLayerColumnHeader = (layer: PluginLayer) => { + if (!pluginInfo.classes[layer]) { + return [ + { + colSpan: 2, + content: 'NO CLASS', + chars: borders.subheader, + }, + ]; + } + + return PLUGIN_LIFECYCLES.map((lifecycle) => ({ + content: getLifecycleColumnHeader(layer, lifecycle), + chars: borders.subheader, + })); + }; + + /** + * True if the `PluginState` is one of the states that should be excluded from a + * mismatch check. + */ + const isExcludedState = (state: PluginState) => + state === 'no class' || state === 'unknown' || state === 'missing'; + + const entries = Object.entries(statuses); + let hasPass = false; + let hasFail = false; + let hasWarn = false; + + // Table Header + table.push([ + { + colSpan: 3, + content: pluginInfo.name, + chars: borders.header, + }, + { + colSpan: 2, + content: 'SERVER', + chars: borders.header, + }, + { + colSpan: 2, + content: 'PUBLIC', + chars: borders.header, + }, + ]); + + // Table Subheader + table.push([ + { + content: '', + chars: borders.subheader, + }, + { + content: 'DEPENDENCY', + chars: borders.subheader, + }, + { + content: 'MANIFEST', + chars: borders.subheader, + }, + ...getLayerColumnHeader('server'), + ...getLayerColumnHeader('client'), + ]); + + // Dependency Rows + entries + .sort(([nameA], [nameB]) => { + return nameA.localeCompare(nameB); + }) + .forEach(([name, statusObj], index) => { + const { manifestState /* server, client*/ } = statusObj; + const chars = index === entries.length - 1 ? borders.lastDependency : {}; + const states = PLUGIN_LAYERS.flatMap((layer) => + PLUGIN_LIFECYCLES.flatMap((lifecycle) => statusObj[layer][lifecycle].pluginState) + ); + + // TODO: Clean all of this brute-force stuff up. + const getLifecycleCellContent = (state: string) => { + if (state === 'no class' || (manifestState === 'bundle' && state === 'missing')) { + return ''; + } else if (manifestState === 'bundle' || (manifestState !== state && state !== 'missing')) { + return colors.red(state === 'missing' ? '' : state); + } + + return state === 'missing' ? '' : state; + }; + + const hasNoMismatch = () => + states.some((state) => state === manifestState) && + states.filter((state) => state !== manifestState).every(isExcludedState); + + const isValidBundle = () => manifestState === 'bundle' && states.every(isExcludedState); + + const getStateLabel = () => { + if (hasNoMismatch() || isValidBundle()) { + hasPass = true; + return '✅'; + } else if (!hasNoMismatch()) { + hasFail = true; + return '❌'; + } + + hasWarn = true; + return '❓'; + }; + + const getLifecycleColumns = () => { + if (noClientPlugin && noServerPlugin) { + return [{ colSpan: 4, content: '' }]; + } + + return PLUGIN_LAYERS.flatMap((layer) => { + if (!pluginInfo.classes[layer]) { + return { colSpan: 2, content: '', chars }; + } + + return PLUGIN_LIFECYCLES.flatMap((lifecycle) => ({ + content: getLifecycleCellContent(statusObj[layer][lifecycle].pluginState), + chars, + })); + }); + }; + + table.push({ + [getStateLabel()]: [ + { content: name, chars }, + { + content: + manifestState === 'missing' ? colors.red(manifestState.toUpperCase()) : manifestState, + chars, + }, + ...getLifecycleColumns(), + ], + }); + }); + + table.push([ + { + colSpan: 7, + content: `${hasWarn ? '❓ - dependency is entirely missing or unknown.\n' : ''}${ + hasFail ? '❌ - dependency differs from the manifest.\n' : '' + }${hasPass ? '✅ - dependency matches the manifest.' : ''}`, + chars: borders.footer, + }, + ]); + + return table; +}; diff --git a/packages/kbn-plugin-check/dependencies/display_dependency_check.ts b/packages/kbn-plugin-check/dependencies/display_dependency_check.ts new file mode 100644 index 0000000000000..3ff1c904cfda8 --- /dev/null +++ b/packages/kbn-plugin-check/dependencies/display_dependency_check.ts @@ -0,0 +1,45 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { PluginOrPackage } from '@kbn/docs-utils/src/types'; +import { ToolingLog } from '@kbn/tooling-log'; +import { Project } from 'ts-morph'; +import { inspect } from 'util'; +import { createTable } from './create_table'; +import { getDependencySummary } from './get_dependency_summary'; +import { getPluginInfo } from './get_plugin_info'; + +/** + * Prepare and output information about a plugin's dependencies. + */ +export const displayDependencyCheck = ( + project: Project, + plugin: PluginOrPackage, + log: ToolingLog +) => { + log.info('Running plugin check on plugin:', plugin.id); + log.indent(4); + + const pluginInfo = getPluginInfo(project, plugin, log); + + if (!pluginInfo) { + log.error(`Cannot find dependencies for plugin ${plugin.id}`); + return; + } + + log.debug('Building dependency summary...'); + + const summary = getDependencySummary(pluginInfo, log); + + log.debug(inspect(summary, true, null, true)); + + const table = createTable(pluginInfo, summary, log); + + log.indent(-4); + log.info(table.toString()); +}; diff --git a/packages/kbn-plugin-check/dependencies/get_dependency_summary.ts b/packages/kbn-plugin-check/dependencies/get_dependency_summary.ts new file mode 100644 index 0000000000000..75b3bdbc2a55c --- /dev/null +++ b/packages/kbn-plugin-check/dependencies/get_dependency_summary.ts @@ -0,0 +1,123 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog } from '@kbn/tooling-log'; +import { PluginInfo, DependencyState, PluginStatuses } from '../types'; + +import { PLUGIN_LAYERS, PLUGIN_LIFECYCLES } from '../const'; + +/** + * Prepares a summary of the plugin's dependencies, based on its manifest and plugin classes. + */ +export const getDependencySummary = (pluginInfo: PluginInfo, log: ToolingLog): PluginStatuses => { + const { + dependencies: { all, manifest, plugin }, + } = pluginInfo; + + const manifestDependencyNames = manifest.all; + + log.debug('All manifest dependencies:', manifestDependencyNames); + + const pluginDependencyNames = plugin.all; + + log.debug('All plugin dependencies:', all); + + // Combine all dependencies, removing duplicates. + const dependencyNames = [ + ...new Set([...pluginDependencyNames, ...manifestDependencyNames]), + ]; + + log.debug('All dependencies:', dependencyNames); + + const plugins: PluginStatuses = {}; + + // For each dependency, add the manifest state to the summary. + dependencyNames.forEach((name) => { + plugins[name] = plugins[name] || { + manifestState: manifest.required.includes(name) + ? 'required' + : manifest.optional.includes(name) + ? 'optional' + : manifest.bundle.includes(name) + ? 'bundle' + : 'missing', + }; + + // For each plugin layer... + PLUGIN_LAYERS.map((layer) => { + // ..initialize the layer object if it doesn't exist. + plugins[name][layer] = plugins[name][layer] || {}; + + // For each plugin lifecycle... + PLUGIN_LIFECYCLES.map((lifecycle) => { + // ...initialize the lifecycle object if it doesn't exist. + plugins[name][layer][lifecycle] = plugins[name][layer][lifecycle] || {}; + + const pluginLifecycle = plugin[layer][lifecycle]; + const source = pluginLifecycle?.source || 'none'; + + if (pluginInfo.classes[layer] === null) { + // If the plugin class for the layer doesn't exist-- e.g. it doesn't have a `server` implementation, + // then set the state to `no class`. + plugins[name][layer][lifecycle] = { typeName: '', pluginState: 'no class', source }; + } else if (source === 'none') { + // If the plugin class for the layer does exist, but the plugin doesn't implement the lifecycle, + // then set the state to `unknown`. + plugins[name][layer][lifecycle] = { typeName: '', pluginState: 'unknown', source }; + } else { + // Set the state of the dependency and its type name. + const typeName = pluginLifecycle?.typeName || `${lifecycle}Type`; + const pluginState = pluginLifecycle?.required.includes(name) + ? 'required' + : pluginLifecycle?.optional.includes(name) + ? 'optional' + : 'missing'; + plugins[name][layer][lifecycle] = { typeName, pluginState, source }; + } + }); + }); + }); + + // Once the statuses of all of the plugins are constructed, determine the overall state of the dependency + // relative to the plugin. + // + // For each dependency... + Object.entries(plugins).forEach(([name, nextPlugin]) => { + const { manifestState, client, server } = nextPlugin; + const { setup, start } = client; + const { setup: serverSetup, start: serverStart } = server; + + // ...create an array of unique states for the dependency derived from the manifest and plugin classes. + const state = [ + ...new Set([ + manifestState, + setup.pluginState, + start.pluginState, + serverSetup.pluginState, + serverStart.pluginState, + ]), + ]; + + // If there is more than one state in the array, then the dependency is in a mismatched state, e.g. + // the manifest states it's `required` but the impl claims it's `optional`. + let status: DependencyState = 'mismatch'; + + if (state.length === 1) { + if (state.includes('required')) { + status = 'required'; + } else if (state.includes('optional')) { + status = 'optional'; + } + } + + // Set the status of the dependency. + plugins[name].status = status; + }); + + return plugins; +}; diff --git a/packages/kbn-plugin-check/dependencies/get_plugin_info.ts b/packages/kbn-plugin-check/dependencies/get_plugin_info.ts new file mode 100644 index 0000000000000..b959f073625fc --- /dev/null +++ b/packages/kbn-plugin-check/dependencies/get_plugin_info.ts @@ -0,0 +1,293 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { ClassDeclaration, MethodDeclaration, Project, SyntaxKind, TypeNode } from 'ts-morph'; + +import { PluginOrPackage } from '@kbn/docs-utils/src/types'; +import { ToolingLog } from '@kbn/tooling-log'; + +import { getPluginClasses } from '../lib/get_plugin_classes'; +import { PluginInfo, PluginLifecycle, PluginLayer, Lifecycle, Dependencies } from '../types'; + +/** + * Derive and return information about a plugin and its dependencies. + */ +export const getPluginInfo = ( + project: Project, + plugin: PluginOrPackage, + log: ToolingLog +): PluginInfo | null => { + const { manifest } = plugin; + + const optionalManifestPlugins = manifest.optionalPlugins || []; + const requiredManifestPlugins = manifest.requiredPlugins || []; + const requiredManifestBundles = manifest.requiredBundles || []; + + const { client, server } = getPluginClasses(project, plugin, log); + + const clientDependencies = getPluginDependencies(client, 'client', log); + const serverDependencies = getPluginDependencies(server, 'server', log); + + // Combine all plugin implementation dependencies, removing duplicates. + const allPluginDependencies = [ + ...new Set([...clientDependencies.all, ...serverDependencies.all]), + ]; + + // Combine all manifest dependencies, removing duplicates. + const allManifestDependencies = [ + ...new Set([ + ...requiredManifestPlugins, + ...optionalManifestPlugins, + ...requiredManifestBundles, + ]), + ]; + + return { + name: plugin.id, + project, + classes: { + client, + server, + }, + dependencies: { + all: [...new Set([...allManifestDependencies, ...allPluginDependencies])], + manifest: { + all: allManifestDependencies, + required: requiredManifestPlugins, + optional: optionalManifestPlugins, + bundle: requiredManifestBundles, + }, + plugin: { + all: allPluginDependencies, + client: clientDependencies, + server: serverDependencies, + }, + }, + }; +}; + +const getPluginDependencies = ( + pluginClass: ClassDeclaration | null, + pluginType: 'client' | 'server', + log: ToolingLog +): Lifecycle => { + // If the plugin class doesn't exist, return an empty object with `null` implementations. + if (!pluginClass) { + return { + all: [], + setup: null, + start: null, + }; + } + + // This is all very brute-force, but it's easier to see and understand what's going on, rather + // than relying on loops and placeholders. YMMV. + const { + source: setupSource, + typeName: setupType, + optional: optionalSetupDependencies, + required: requiredSetupDependencies, + } = getDependenciesFromLifecycleType(pluginClass, 'setup', pluginType, log); + + const { + source: startSource, + typeName: startType, + optional: optionalStartDependencies, + required: requiredStartDependencies, + } = getDependenciesFromLifecycleType(pluginClass, 'start', pluginType, log); + + return { + all: [ + ...new Set([ + ...requiredSetupDependencies, + ...optionalSetupDependencies, + ...requiredStartDependencies, + ...optionalStartDependencies, + ]), + ], + setup: { + all: [...new Set([...requiredSetupDependencies, ...optionalSetupDependencies])], + source: setupSource, + typeName: setupType, + required: requiredSetupDependencies, + optional: optionalSetupDependencies, + }, + start: { + all: [...new Set([...requiredStartDependencies, ...optionalStartDependencies])], + source: startSource, + typeName: startType, + required: requiredStartDependencies, + optional: optionalStartDependencies, + }, + }; +}; + +/** + * Given a lifecycle type, derive the dependencies for that lifecycle. + */ +const getDependenciesFromLifecycleType = ( + pluginClass: ClassDeclaration, + lifecycle: PluginLifecycle, + layer: PluginLayer, + log: ToolingLog +): Dependencies => { + const className = pluginClass.getName(); + log.debug(`${layer}/${className}/${lifecycle} discovering dependencies.`); + + const classImplements = pluginClass.getImplements(); + + if (!classImplements || classImplements.length === 0) { + log.warning(`${layer}/${className} plugin class does not extend the Core Plugin interface.`); + } else { + // This is safe, as we don't allow more than one class per file. + const typeArguments = classImplements[0].getTypeArguments(); + + // The `Plugin` generic has 4 type arguments, the 3rd of which is an interface of `setup` + // dependencies, and the fourth being an interface of `start` dependencies. + const type = typeArguments[lifecycle === 'setup' ? 2 : 3]; + + // If the type is defined, we can derive the dependencies directly from it. + if (type) { + const dependencies = getDependenciesFromNode(type, log); + + if (dependencies) { + return dependencies; + } + } else { + // ...and we can warn if the type is not defined. + log.warning( + `${layer}/${className}/${lifecycle} dependencies not defined on core interface generic.` + ); + } + } + + // If the type is not defined or otherwise unavailable, it's possible to derive the lifecycle + // dependencies directly from the instance method. + log.debug( + `${layer}/${className}/${lifecycle} falling back to instance method to derive dependencies.` + ); + + const methods = pluginClass.getInstanceMethods(); + + // Find the method on the class that matches the lifecycle name. + const method = methods.find((m) => m.getName() === (lifecycle === 'setup' ? 'setup' : 'start')); + + // As of now, a plugin cannot omit a lifecycle method, so throw an error. + if (!method) { + throw new Error( + `${layer}/${className}/${lifecycle} method does not exist; this should not be possible.` + ); + } + + // Given a method, derive the dependencies. + const dependencies = getDependenciesFromMethod(method, log); + + if (dependencies) { + return dependencies; + } + + log.warning( + `${layer}/${className}/${lifecycle} dependencies also not defined on lifecycle method.` + ); + + // At this point, there's no way to derive the dependencies, so return an empty object. + return { + all: [], + source: 'none', + typeName: null, + required: [], + optional: [], + }; +}; + +/** Derive dependencies from a `TypeNode`-- the lifecycle method itself. */ +const getDependenciesFromNode = ( + node: TypeNode | undefined, + _log: ToolingLog +): Dependencies | null => { + if (!node) { + return null; + } + + const typeName = node.getText(); + + // Get all of the dependencies and whether or not they're required. + const dependencies = node + .getType() + .getSymbol() + ?.getMembers() + .map((member) => { + return { name: member.getName(), isOptional: member.isOptional() }; + }); + + // Split the dependencies into required and optional. + const optional = + dependencies + ?.filter((dependency) => dependency.isOptional) + .map((dependency) => dependency.name) || []; + + const required = + dependencies + ?.filter((dependency) => !dependency.isOptional) + .map((dependency) => dependency.name) || []; + + return { + all: [...new Set([...required, ...optional])], + // Set the `source` to `implements`, as the dependencies were derived from the method + // implementation, rather than an explicit type. + source: 'implements', + typeName, + required, + optional, + }; +}; + +const getDependenciesFromMethod = ( + method: MethodDeclaration, + _log: ToolingLog +): Dependencies | null => { + if (!method) { + return null; + } + + const dependencyObj = method.getParameters()[1]; + + if (!dependencyObj) { + return null; + } + + const typeRef = dependencyObj.getDescendantsOfKind(SyntaxKind.TypeReference)[0]; + + if (!typeRef) { + return null; + } + + const symbol = typeRef.getType().getSymbol(); + + const dependencies = symbol?.getMembers().map((member) => { + return { name: member.getName(), isOptional: member.isOptional() }; + }); + + const optional = + dependencies + ?.filter((dependency) => dependency.isOptional) + .map((dependency) => dependency.name) || []; + + const required = + dependencies + ?.filter((dependency) => !dependency.isOptional) + .map((dependency) => dependency.name) || []; + + return { + all: [...new Set([...required, ...optional])], + source: 'method', + typeName: symbol?.getName() || null, + required, + optional, + }; +}; diff --git a/packages/kbn-plugin-check/dependencies/index.ts b/packages/kbn-plugin-check/dependencies/index.ts new file mode 100644 index 0000000000000..5a77c64c25bc4 --- /dev/null +++ b/packages/kbn-plugin-check/dependencies/index.ts @@ -0,0 +1,47 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { Flags } from '@kbn/dev-cli-runner'; +import { findTeamPlugins } from '@kbn/docs-utils'; +import { ToolingLog } from '@kbn/tooling-log'; +import { Project } from 'ts-morph'; +import { getPlugin } from '../lib'; +import { displayDependencyCheck } from './display_dependency_check'; + +export const checkDependencies = (flags: Flags, log: ToolingLog) => { + const checkPlugin = (name: string) => { + const plugin = getPlugin(name, log); + + if (!plugin) { + log.error(`Cannot find plugin ${name}`); + return; + } + + const project = new Project({ + tsConfigFilePath: `${plugin.directory}/tsconfig.json`, + }); + + displayDependencyCheck(project, plugin, log); + }; + + const pluginOrTeam = typeof flags.dependencies === 'string' ? flags.dependencies : undefined; + + if (!pluginOrTeam) { + return; + } + + if (pluginOrTeam.startsWith('@elastic/')) { + const plugins = findTeamPlugins(pluginOrTeam); + + plugins.forEach((plugin) => { + checkPlugin(plugin.manifest.id); + }); + } else { + checkPlugin(pluginOrTeam); + } +}; diff --git a/packages/kbn-plugin-check/dependencies/table_borders.ts b/packages/kbn-plugin-check/dependencies/table_borders.ts new file mode 100644 index 0000000000000..523c86dcdf6fa --- /dev/null +++ b/packages/kbn-plugin-check/dependencies/table_borders.ts @@ -0,0 +1,71 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { CharName } from 'cli-table3'; + +type Borders = Record>>; + +/** + * A utility collection of table border settings for use with `cli-table3`. + */ +export const borders: Borders = { + table: { + 'bottom-left': '╚', + 'bottom-right': '╝', + 'left-mid': '╟', + 'right-mid': '╢', + 'top-left': '╔', + 'top-right': '╗', + left: '║', + right: '║', + }, + header: { + 'bottom-left': '╚', + 'bottom-mid': '╤', + 'bottom-right': '╝', + 'mid-mid': '╪', + 'top-mid': '╤', + bottom: '═', + mid: '═', + top: '═', + }, + subheader: { + 'left-mid': '╠', + 'mid-mid': '╪', + 'right-mid': '╣', + 'top-left': '╠', + 'top-mid': '╤', + 'top-right': '╣', + bottom: '═', + mid: '═', + top: '╤', + }, + lastDependency: { + 'bottom-left': '╚', + 'bottom-mid': '═', + 'bottom-right': '╝', + bottom: '═', + }, + footer: { + 'bottom-left': '╚', + 'bottom-mid': '╧', + 'bottom-right': '╝', + 'left-mid': '╠', + 'mid-mid': '═', + 'right-mid': '╣', + 'top-left': '╠', + 'top-mid': '╤', + 'top-right': '╣', + bottom: '═', + left: '║', + mid: '═', + middle: '═', + right: '║', + top: '═', + }, +}; diff --git a/packages/kbn-plugin-check/dependents.ts b/packages/kbn-plugin-check/dependents.ts new file mode 100644 index 0000000000000..b3b090c2eafd2 --- /dev/null +++ b/packages/kbn-plugin-check/dependents.ts @@ -0,0 +1,63 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { ToolingLog } from '@kbn/tooling-log'; + +import { getAllPlugins } from './lib'; + +interface Dependents { + required: readonly string[]; + optional: readonly string[]; + bundles?: readonly string[]; +} + +export const findDependents = (plugin: string, log: ToolingLog): Dependents => { + log.info(`Finding dependents for ${plugin}`); + const plugins = getAllPlugins(log); + const required: string[] = []; + const optional: string[] = []; + const bundles: string[] = []; + + plugins.forEach((p) => { + const manifest = p.manifest; + + if (manifest.requiredPlugins?.includes(plugin)) { + required.push(manifest.id); + } + + if (manifest.optionalPlugins?.includes(plugin)) { + optional.push(manifest.id); + } + + if (manifest.requiredBundles?.includes(plugin)) { + bundles.push(manifest.id); + } + }); + + if (required.length === 0 && optional.length === 0 && bundles.length === 0) { + log.info(`No plugins depend on ${plugin}`); + } + + if (required.length > 0) { + log.info(`REQUIRED BY ${required.length}:\n${required.join('\n')}\n`); + } + + if (optional.length > 0) { + log.info(`OPTIONAL FOR ${optional.length}:\n${optional.join('\n')}\n`); + } + + if (bundles.length > 0) { + log.info(`BUNDLE FOR ${bundles.length}:\n${bundles.join('\n')}\n`); + } + + return { + required, + optional, + bundles, + }; +}; diff --git a/packages/kbn-plugin-check/index.ts b/packages/kbn-plugin-check/index.ts new file mode 100644 index 0000000000000..ffb38708bc5c0 --- /dev/null +++ b/packages/kbn-plugin-check/index.ts @@ -0,0 +1,56 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { run } from '@kbn/dev-cli-runner'; +import { checkDependencies } from './dependencies'; +import { rankDependencies } from './rank'; +import { findDependents } from './dependents'; + +/** + * A CLI for checking the consistency of a plugin's declared and implicit dependencies. + */ +export const runPluginCheckCli = () => { + run( + async ({ log, flags }) => { + if ( + (flags.dependencies && flags.rank) || + (flags.dependencies && flags.dependents) || + (flags.rank && flags.dependents) + ) { + throw new Error('Only one of --dependencies, --rank, or --dependents may be specified.'); + } + + if (flags.dependencies) { + checkDependencies(flags, log); + } + + if (flags.rank) { + rankDependencies(log); + } + + if (flags.dependents && typeof flags.dependents === 'string') { + findDependents(flags.dependents, log); + } + }, + { + log: { + defaultLevel: 'info', + }, + flags: { + boolean: ['rank'], + string: ['dependencies', 'dependents'], + help: ` + --rank Display plugins as a ranked list of usage. + --dependents [plugin] Display plugins that depend on a given plugin. + --dependencies [plugin] Check plugin dependencies for a single plugin. + --dependencies [team] Check dependencies for all plugins owned by a team. + `, + }, + } + ); +}; diff --git a/packages/kbn-plugin-check/jest.config.js b/packages/kbn-plugin-check/jest.config.js new file mode 100644 index 0000000000000..32cff56123cd7 --- /dev/null +++ b/packages/kbn-plugin-check/jest.config.js @@ -0,0 +1,13 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-plugin-check'], +}; diff --git a/packages/kbn-plugin-check/kibana.jsonc b/packages/kbn-plugin-check/kibana.jsonc new file mode 100644 index 0000000000000..5081138fe5e2a --- /dev/null +++ b/packages/kbn-plugin-check/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-common", + "id": "@kbn/plugin-check", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/kbn-plugin-check/lib/get_all_plugins.ts b/packages/kbn-plugin-check/lib/get_all_plugins.ts new file mode 100644 index 0000000000000..33cc5155bdf3c --- /dev/null +++ b/packages/kbn-plugin-check/lib/get_all_plugins.ts @@ -0,0 +1,20 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { findPlugins } from '@kbn/docs-utils'; +import { ToolingLog } from '@kbn/tooling-log'; + +/** + * Utility method for finding and logging information about all plugins. + */ +export const getAllPlugins = (log: ToolingLog) => { + const plugins = findPlugins().filter((plugin) => plugin.isPlugin); + log.info(`Found ${plugins.length} plugins.`); + log.debug('Found plugins:', plugins); + return plugins; +}; diff --git a/packages/kbn-plugin-check/lib/get_plugin.ts b/packages/kbn-plugin-check/lib/get_plugin.ts new file mode 100644 index 0000000000000..d346f646c1b7f --- /dev/null +++ b/packages/kbn-plugin-check/lib/get_plugin.ts @@ -0,0 +1,19 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { findPlugins } from '@kbn/docs-utils'; +import { ToolingLog } from '@kbn/tooling-log'; + +/** + * Utility method for finding and logging information about a plugin. + */ +export const getPlugin = (pluginName: string, log: ToolingLog) => { + const plugin = findPlugins([pluginName])[0]; + log.debug('Found plugin:', pluginName); + return plugin; +}; diff --git a/packages/kbn-plugin-check/lib/get_plugin_classes.ts b/packages/kbn-plugin-check/lib/get_plugin_classes.ts new file mode 100644 index 0000000000000..d7e40b4a80930 --- /dev/null +++ b/packages/kbn-plugin-check/lib/get_plugin_classes.ts @@ -0,0 +1,46 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { Project } from 'ts-morph'; + +import { ToolingLog } from '@kbn/tooling-log'; +import { PluginOrPackage } from '@kbn/docs-utils/src/types'; + +/** + * Return the `client` and `server` plugin classes for a plugin. + */ +export const getPluginClasses = (project: Project, plugin: PluginOrPackage, log: ToolingLog) => { + // The `client` and `server` plugins have a consistent name and directory structure, but the + // `client` plugin _may_ be a `.tsx` file, so we need to check for both. + let client = project.getSourceFile(`${plugin.directory}/public/plugin.ts`); + + if (!client) { + client = project.getSourceFile(`${plugin.directory}/public/plugin.tsx`); + } + + const server = project.getSourceFile(`${plugin.directory}/server/plugin.ts`); + + // Log the warning if one or both plugin implementations are missing. + if (!client || !server) { + if (!client) { + log.warning(`${plugin.id}/client: no plugin.`); + } + + if (!server) { + log.warning(`${plugin.id}/server: no plugin.`); + } + } + + // We restrict files to a single class, so assigning the first element from the + // resulting array should be fine. + return { + project, + client: client ? client.getClasses()[0] : null, + server: server ? server.getClasses()[0] : null, + }; +}; diff --git a/packages/kbn-plugin-check/lib/index.ts b/packages/kbn-plugin-check/lib/index.ts new file mode 100644 index 0000000000000..55d1496823220 --- /dev/null +++ b/packages/kbn-plugin-check/lib/index.ts @@ -0,0 +1,11 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +export { getPlugin } from './get_plugin'; +export { getPluginClasses } from './get_plugin_classes'; +export { getAllPlugins } from './get_all_plugins'; diff --git a/packages/kbn-plugin-check/package.json b/packages/kbn-plugin-check/package.json new file mode 100644 index 0000000000000..2db46fce1c069 --- /dev/null +++ b/packages/kbn-plugin-check/package.json @@ -0,0 +1,6 @@ +{ + "name": "@kbn/plugin-check", + "private": true, + "version": "1.0.0", + "license": "SSPL-1.0 OR Elastic License 2.0" +} \ No newline at end of file diff --git a/packages/kbn-plugin-check/rank.ts b/packages/kbn-plugin-check/rank.ts new file mode 100644 index 0000000000000..5089fd1deee7e --- /dev/null +++ b/packages/kbn-plugin-check/rank.ts @@ -0,0 +1,116 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { MultiBar, Presets } from 'cli-progress'; + +import { ToolingLog } from '@kbn/tooling-log'; + +import { getAllPlugins } from './lib'; + +interface Dependencies { + required: readonly string[]; + optional: readonly string[]; + bundles?: readonly string[]; +} + +const getSpaces = (size: number, count: number) => { + const length = count > 9 && count < 100 ? 2 : count < 10 ? 1 : 3; + return ' '.repeat(size - length); +}; + +export const rankDependencies = (log: ToolingLog) => { + const plugins = getAllPlugins(log); + + const pluginMap = new Map(); + const pluginRequired = new Map(); + const pluginOptional = new Map(); + const pluginBundles = new Map(); + let minWidth = 0; + + plugins.forEach((plugin) => { + pluginMap.set(plugin.manifest.id, { + required: plugin.manifest.requiredPlugins || [], + optional: plugin.manifest.optionalPlugins || [], + bundles: plugin.manifest.requiredBundles || [], + }); + + if (plugin.manifest.id.length > minWidth) { + minWidth = plugin.manifest.id.length; + } + }); + + pluginMap.forEach((dependencies) => { + dependencies.required.forEach((required) => { + pluginRequired.set(required, (pluginRequired.get(required) || 0) + 1); + }); + + dependencies.optional.forEach((optional) => { + pluginOptional.set(optional, (pluginOptional.get(optional) || 0) + 1); + }); + + dependencies.bundles?.forEach((bundle) => { + pluginBundles.set(bundle, (pluginBundles.get(bundle) || 0) + 1); + }); + }); + + const sorted = [...pluginMap.entries()].sort((a, b) => { + const aRequired = pluginRequired.get(a[0]) || 0; + const aOptional = pluginOptional.get(a[0]) || 0; + const aBundles = pluginBundles.get(a[0]) || 0; + const aTotal = aRequired + aOptional + aBundles; + + const bRequired = pluginRequired.get(b[0]) || 0; + const bOptional = pluginOptional.get(b[0]) || 0; + const bBundles = pluginBundles.get(b[0]) || 0; + const bTotal = bRequired + bOptional + bBundles; + + return bTotal - aTotal; + }); + + log.debug(`Ranking ${sorted.length} plugins.`); + + // sorted.forEach((plugin) => { + // log.info(`${plugin[0]}: ${plugin[1]}/${pluginOptional.get(plugin[0]) || 0}`); + // }); + + const multiBar = new MultiBar( + { + clearOnComplete: false, + hideCursor: true, + format: ' {bar} | {plugin} | {usage} | {info}', + }, + Presets.shades_grey + ); + + multiBar.create(sorted.length, sorted.length, { + plugin: `${sorted.length} plugins${' '.repeat(minWidth - 11)}`, + usage: 'total', + info: 'req opt bun', + }); + + sorted.forEach(([plugin]) => { + const total = sorted.length; + const optional = pluginOptional.get(plugin) || 0; + const required = pluginRequired.get(plugin) || 0; + const bundles = pluginBundles.get(plugin) || 0; + const usage = optional + required + bundles; + + multiBar.create(total, required + optional + bundles, { + plugin: `${plugin}${' '.repeat(minWidth - plugin.length)}`, + info: `${required}${getSpaces(4, required)}${optional}${getSpaces( + 4, + optional + )}${bundles}${getSpaces(4, bundles)}`, + usage: `${usage}${getSpaces(5, usage)}`, + }); + }); + + multiBar.stop(); + + return sorted; +}; diff --git a/packages/kbn-plugin-check/tsconfig.json b/packages/kbn-plugin-check/tsconfig.json new file mode 100644 index 0000000000000..3daf5600fa25d --- /dev/null +++ b/packages/kbn-plugin-check/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts", + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/tooling-log", + "@kbn/docs-utils", + "@kbn/dev-cli-runner", + ] +} diff --git a/packages/kbn-plugin-check/types.ts b/packages/kbn-plugin-check/types.ts new file mode 100644 index 0000000000000..739644f21cdcf --- /dev/null +++ b/packages/kbn-plugin-check/types.ts @@ -0,0 +1,119 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +import { ClassDeclaration, Project } from 'ts-morph'; +import { + PLUGIN_LAYERS, + PLUGIN_LIFECYCLES, + PLUGIN_REQUIREMENTS, + MANIFEST_STATES, + DEPENDENCY_STATES, + PLUGIN_STATES, + SOURCE_OF_TYPE, + MANIFEST_REQUIREMENTS, +} from './const'; + +/** An enumeration of plugin classes within a single plugin. */ +export type PluginLayer = typeof PLUGIN_LAYERS[number]; + +/** An enumeration of dependency requirements for a plugin. */ +export type PluginRequirement = typeof PLUGIN_REQUIREMENTS[number]; + +/** An enumeration of lifecycles a plugin should implement. */ +export type PluginLifecycle = typeof PLUGIN_LIFECYCLES[number]; + +/** An enumeration of manifest requirement states for a plugin dependency. */ +export type ManifestRequirement = typeof MANIFEST_REQUIREMENTS[number]; + +/** An enumeration of derived manifest states for a plugin dependency. */ +export type ManifestState = typeof MANIFEST_STATES[number]; + +/** An enumeration of derived plugin states for a dependency. */ +export type PluginState = typeof PLUGIN_STATES[number]; + +/** An enumeration of derived dependency states. */ +export type DependencyState = typeof DEPENDENCY_STATES[number]; + +/** An enumeration of where a type could be derived from a plugin class. */ +export type SourceOfType = typeof SOURCE_OF_TYPE[number]; + +/** Information about a given plugin. */ +export interface PluginInfo { + /** The unique Kibana identifier for the plugin. */ + name: string; + /** + * The `ts-morph` project for the plugin; this is expensive to create, so it is + * also stored here. + */ + project: Project; + /** Class Declarations from `ts-morph` for the plugin layers. */ + classes: { + /** Class Declarations from `ts-morph` for the `client` plugin layer. */ + client: ClassDeclaration | null; + /** Class Declarations from `ts-morph` for the `server` plugin layer. */ + server: ClassDeclaration | null; + }; + /** Dependencies and their states for the plugin. */ + dependencies: { + /** Dependencies derived from the manifest. */ + manifest: ManifestDependencies; + /** Dependencies derived from the plugin code. */ + plugin: PluginDependencies; + } & All; +} + +// Convenience type to include an `all` field of combined, unique dependency names +// for any given subset. +interface All { + all: Readonly; +} + +/** Dependencies organized by whether or not they are required. */ +export type Dependencies = { + [requirement in PluginRequirement]: Readonly; +} & { + /** From where the dependencies were derived-- e.g. a type or instance method. */ + source: SourceOfType; + /** The name of the type, if any. */ + typeName: string | null; +} & All; + +/** Dependencies organized by plugin lifecycle. */ +export type Lifecycle = { + [lifecycle in PluginLifecycle]: Dependencies | null; +} & All; + +/** Dependencies organized by plugin layer. */ +export type PluginDependencies = { + [layer in PluginLayer]: Lifecycle; +} & All; + +/** Dependencies organized by manifest requirement. */ +export type ManifestDependencies = { + [requirement in ManifestRequirement]: Readonly; +} & All; + +// The hierarchical representation of a plugin's dependencies: +// plugin layer -> lifecycle -> requirement -> dependency info +type PluginStatus = { + [layer in PluginLayer]: { + [lifecycle in PluginLifecycle]: { + typeName: string; + source: SourceOfType; + pluginState: PluginState; + }; + }; +}; + +/** A map of dependencies and their status organized by name. */ +export interface PluginStatuses { + [pluginId: string]: PluginStatus & { + status: DependencyState; + manifestState: ManifestState; + }; +} diff --git a/scripts/plugin_check.js b/scripts/plugin_check.js new file mode 100644 index 0000000000000..acdc92a9dfa9d --- /dev/null +++ b/scripts/plugin_check.js @@ -0,0 +1,10 @@ +/* + * 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 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 or the Server + * Side Public License, v 1. + */ + +require('../src/setup_node_env'); +require('@kbn/plugin-check').runPluginCheckCli(); diff --git a/tsconfig.base.json b/tsconfig.base.json index 3ccdd4db81eb7..a4770a13af2c8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1168,6 +1168,8 @@ "@kbn/performance-testing-dataset-extractor/*": ["packages/kbn-performance-testing-dataset-extractor/*"], "@kbn/picomatcher": ["packages/kbn-picomatcher"], "@kbn/picomatcher/*": ["packages/kbn-picomatcher/*"], + "@kbn/plugin-check": ["packages/kbn-plugin-check"], + "@kbn/plugin-check/*": ["packages/kbn-plugin-check/*"], "@kbn/plugin-generator": ["packages/kbn-plugin-generator"], "@kbn/plugin-generator/*": ["packages/kbn-plugin-generator/*"], "@kbn/plugin-helpers": ["packages/kbn-plugin-helpers"], diff --git a/x-pack/plugins/apm/scripts/infer_route_return_types/index.ts b/x-pack/plugins/apm/scripts/infer_route_return_types/index.ts index c3fb30e2d17ac..ce4ba6a9b16df 100644 --- a/x-pack/plugins/apm/scripts/infer_route_return_types/index.ts +++ b/x-pack/plugins/apm/scripts/infer_route_return_types/index.ts @@ -99,10 +99,10 @@ files.forEach((file) => { .literal as ts.StringLiteral; // replace absolute paths with relative paths - return ts.updateImportTypeNode( + return ts.factory.updateImportTypeNode( node, - ts.createLiteralTypeNode( - ts.createStringLiteral( + ts.factory.createLiteralTypeNode( + ts.factory.createStringLiteral( `./${Path.relative( Path.dirname(file.getFilePath()), literal.text diff --git a/yarn.lock b/yarn.lock index 4e5bc58fb1905..6249cc68cf95f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5396,6 +5396,10 @@ version "0.0.0" uid "" +"@kbn/plugin-check@link:packages/kbn-plugin-check": + version "0.0.0" + uid "" + "@kbn/plugin-generator@link:packages/kbn-plugin-generator": version "0.0.0" uid "" @@ -8774,13 +8778,13 @@ resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad" integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA== -"@ts-morph/common@~0.12.2": - version "0.12.2" - resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.12.2.tgz#61d07a47d622d231e833c44471ab306faaa41aed" - integrity sha512-m5KjptpIf1K0t0QL38uE+ol1n+aNn9MgRq++G3Zym1FlqfN+rThsXlp3cAgib14pIeXF7jk3UtJQOviwawFyYg== +"@ts-morph/common@~0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@ts-morph/common/-/common-0.16.0.tgz#57e27d4b3fd65a4cd72cb36679ed08acb40fa3ba" + integrity sha512-SgJpzkTgZKLKqQniCjLaE3c2L2sdL7UShvmTmPBejAKd2OKV/yfMpQ2IWpAuA+VY5wy7PkSUaEObIqEK6afFuw== dependencies: - fast-glob "^3.2.7" - minimatch "^3.0.4" + fast-glob "^3.2.11" + minimatch "^5.1.0" mkdirp "^1.0.4" path-browserify "^1.0.1" @@ -16874,7 +16878,7 @@ fast-glob@^2.2.6: merge2 "^1.2.3" micromatch "^3.1.10" -fast-glob@^3.0.3, fast-glob@^3.1.1, fast-glob@^3.2.11, fast-glob@^3.2.2, fast-glob@^3.2.7, fast-glob@^3.2.9, fast-glob@^3.3.2: +fast-glob@^3.0.3, fast-glob@^3.1.1, fast-glob@^3.2.11, fast-glob@^3.2.2, fast-glob@^3.2.9, fast-glob@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== @@ -22432,7 +22436,7 @@ minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatc dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1: +minimatch@^5.0.1, minimatch@^5.1.0: version "5.1.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== @@ -29645,12 +29649,12 @@ ts-easing@^0.2.0: resolved "https://registry.yarnpkg.com/ts-easing/-/ts-easing-0.2.0.tgz#c8a8a35025105566588d87dbda05dd7fbfa5a4ec" integrity sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ== -ts-morph@^13.0.2: - version "13.0.2" - resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-13.0.2.tgz#55546023493ef82389d9e4f28848a556c784bac4" - integrity sha512-SjeeHaRf/mFsNeR3KTJnx39JyEOzT4e+DX28gQx5zjzEOuFs2eGrqeN2PLKs/+AibSxPmzV7RD8nJVKmFJqtLA== +ts-morph@^15.1.0: + version "15.1.0" + resolved "https://registry.yarnpkg.com/ts-morph/-/ts-morph-15.1.0.tgz#53deea5296d967ff6eba8f15f99d378aa7074a4e" + integrity sha512-RBsGE2sDzUXFTnv8Ba22QfeuKbgvAGJFuTN7HfmIRUkgT/NaVLfDM/8OFm2NlFkGlWEXdpW5OaFIp1jvqdDuOg== dependencies: - "@ts-morph/common" "~0.12.2" + "@ts-morph/common" "~0.16.0" code-block-writer "^11.0.0" ts-node@^10.9.1: