[dx] Dependency check script for plugins (#171483)

## 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.

<img width="735" alt="Screenshot 2023-12-13 at 10 08 00 AM"
src="b4201c86-4811-4506-b2d0-be5bf8c372b0">

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.

<img width="1426" alt="Screenshot 2023-12-13 at 12 37 25 AM"
src="e044afb7-26f5-4d96-92db-d2eb0a3dfc6e">
<img width="1413" alt="Screenshot 2023-12-13 at 12 38 07 AM"
src="69217a34-9840-4d32-98de-eeeb863d4a50">
<img width="1071" alt="Screenshot 2023-12-13 at 12 38 35 AM"
src="57736027-2d10-44bf-8230-29fdb8b77cb2">

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.
This commit is contained in:
Clint Andrew Hall 2024-01-17 17:19:41 -05:00 committed by GitHub
parent 64ebaffd89
commit 2f92ce1d4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1414 additions and 18 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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",

View file

@ -7,3 +7,5 @@
*/
export { runBuildApiDocsCli } from './src';
export { findPlugins, findTeamPlugins } from './src/find_plugins';

View file

@ -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.
*/

View file

@ -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;

View file

@ -0,0 +1,5 @@
{
"rules": {
"import/no-extraneous-dependencies": "off"
}
}

View file

@ -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
```

View file

@ -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;

View file

@ -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;
};

View file

@ -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());
};

View file

@ -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<string>([...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<string>([
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;
};

View file

@ -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,
};
};

View file

@ -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);
}
};

View file

@ -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<string, Partial<Record<CharName, string>>>;
/**
* 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: '═',
},
};

View file

@ -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,
};
};

View file

@ -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.
`,
},
}
);
};

View file

@ -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: ['<rootDir>/packages/kbn-plugin-check'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/plugin-check",
"owner": "@elastic/appex-sharedux"
}

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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,
};
};

View file

@ -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';

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/plugin-check",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -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<string, Dependencies>();
const pluginRequired = new Map<string, number>();
const pluginOptional = new Map<string, number>();
const pluginBundles = new Map<string, number>();
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;
};

View file

@ -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",
]
}

View file

@ -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<string[]>;
}
/** Dependencies organized by whether or not they are required. */
export type Dependencies = {
[requirement in PluginRequirement]: Readonly<string[]>;
} & {
/** 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<string[]>;
} & 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;
};
}

10
scripts/plugin_check.js Normal file
View file

@ -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();

View file

@ -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"],

View file

@ -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

View file

@ -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: