Merge pull request #8965 from backstage/rugvip/extractor

cli,catalog-model: add experimental type definitions with release stage split
This commit is contained in:
Patrik Oldsberg
2022-01-18 11:06:14 +01:00
committed by GitHub
22 changed files with 336 additions and 62 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@backstage/cli': patch
---
Introduced a new `--experimental-type-build` option to the various package build commands. This option switches the type definition build to be executed using API Extractor rather than a `rollup` plugin. In order for this experimental switch to work, you must also have `@microsoft/api-extractor` installed within your project, as it is an optional peer dependency.
The biggest difference between the existing mode and the experimental one is that rather than just building a single `dist/index.d.ts` file, API Extractor will output `index.d.ts`, `index.beta.d.ts`, and `index.alpha.d.ts`, all in the `dist` directory. Each of these files will have exports from more unstable release stages stripped, meaning that `index.d.ts` will omit all exports marked with `@alpha` or `@beta`, while `index.beta.d.ts` will omit all exports marked with `@alpha`.
In addition, the `prepack` command now has support for `alphaTypes` and `betaTypes` fields in the `publishConfig` of `package.json`. These optional fields can be pointed to `dist/types.alpha.d.ts` and `dist/types.beta.d.ts` respectively, which will cause `<name>/alpha` and `<name>/beta` entry points to be generated for the package. See the [`@backstage/catalog-model`](https://github.com/backstage/backstage/blob/master/packages/catalog-model/package.json) package for an example of how this can be used in practice.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-backend': patch
---
Internal update to match status field changes in `@backstage/catalog-model`.
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/catalog-model': patch
---
Added `alpha` release stage type declarations, accessible via `@backstage/catalog-model/alpha`.
**BREAKING**: The experimental entity `status` field was removed from the base `Entity` type and is now only available on the `AlphaEntity` type from the alpha release stage, along with all accompanying types that were previously marked as `UNSTABLE_`.
For example, the following import:
```ts
import { UNSTABLE_EntityStatusItem } from '@backstage/catalog-model';
```
Becomes this:
```ts
import { EntityStatusItem } from '@backstage/catalog-model/alpha';
```
Note that exports that are only available from the alpha release stage are considered unstable and do not adhere to the semantic versioning of the package.
+21 -21
View File
@@ -9,6 +9,11 @@ import { JsonValue } from '@backstage/types';
import { SerializedError } from '@backstage/errors';
import * as yup from 'yup';
// @alpha
export interface AlphaEntity extends Entity {
status?: EntityStatus;
}
// @public @deprecated
export const analyzeLocationSchema: yup.SchemaOf<{
location: LocationSpec;
@@ -116,7 +121,6 @@ export type Entity = {
metadata: EntityMeta;
spec?: JsonObject;
relations?: EntityRelation[];
status?: UNSTABLE_EntityStatus;
};
// @public
@@ -225,6 +229,22 @@ export function entitySchemaValidator<T extends Entity = Entity>(
schema?: unknown,
): (data: unknown) => T;
// @alpha
export type EntityStatus = {
items?: EntityStatusItem[];
};
// @alpha
export type EntityStatusItem = {
type: string;
level: EntityStatusLevel;
message: string;
error?: SerializedError;
};
// @alpha
export type EntityStatusLevel = 'info' | 'warning' | 'error';
// @public
export class FieldFormatEntityPolicy implements EntityPolicy {
constructor(validators?: Validators);
@@ -550,22 +570,6 @@ export interface TemplateEntityV1beta2 extends Entity {
// @public
export const templateEntityV1beta2Validator: KindValidator;
// @alpha
export type UNSTABLE_EntityStatus = {
items?: UNSTABLE_EntityStatusItem[];
};
// @alpha
export type UNSTABLE_EntityStatusItem = {
type: string;
level: UNSTABLE_EntityStatusLevel;
message: string;
error?: SerializedError;
};
// @alpha
export type UNSTABLE_EntityStatusLevel = 'info' | 'warning' | 'error';
// @public
interface UserEntityV1alpha1 extends Entity {
// (undocumented)
@@ -603,8 +607,4 @@ export type Validators = {
// @public
export const VIEW_URL_ANNOTATION = 'backstage.io/view-url';
// Warnings were encountered during analysis:
//
// src/entity/Entity.d.ts:41:5 - (ae-incompatible-release-tags) The symbol "status" is marked as @public, but its signature references "UNSTABLE_EntityStatus" which is marked as @alpha
```
+5 -3
View File
@@ -10,7 +10,8 @@
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
"types": "dist/index.d.ts",
"alphaTypes": "dist/index.alpha.d.ts"
},
"homepage": "https://backstage.io",
"repository": {
@@ -22,7 +23,7 @@
"backstage"
],
"scripts": {
"build": "backstage-cli build",
"build": "backstage-cli build --experimental-type-build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
@@ -48,6 +49,7 @@
"yaml": "^1.9.2"
},
"files": [
"dist"
"dist",
"alpha"
]
}
+14 -3
View File
@@ -16,7 +16,7 @@
import { JsonObject } from '@backstage/types';
import { EntityName } from '../types';
import { UNSTABLE_EntityStatus } from './EntityStatus';
import { EntityStatus } from './EntityStatus';
/**
* The parts of the format that's common to all versions/kinds of entity.
@@ -53,15 +53,26 @@ export type Entity = {
* The relations that this entity has with other entities.
*/
relations?: EntityRelation[];
};
/**
* A version of the {@link Entity} type that contains unstable alpha fields.
*
* @remarks
*
* Available via the `@backstage/catalog-model/alpha` import.
*
* @alpha
*/
export interface AlphaEntity extends Entity {
/**
* The current status of the entity, as claimed by various sources.
*
* The keys are implementation defined and the values can be any JSON object
* with semantics that match that implementation.
*/
status?: UNSTABLE_EntityStatus;
};
status?: EntityStatus;
}
/**
* Metadata fields common to all versions/kinds of entity.
@@ -21,18 +21,18 @@ import { SerializedError } from '@backstage/errors';
*
* @alpha
*/
export type UNSTABLE_EntityStatus = {
export type EntityStatus = {
/**
* Specific status item on a well known format.
*/
items?: UNSTABLE_EntityStatusItem[];
items?: EntityStatusItem[];
};
/**
* A specific status item on a well known format.
* @alpha
*/
export type UNSTABLE_EntityStatusItem = {
export type EntityStatusItem = {
/**
* The type of status as a unique key per source.
*/
@@ -43,7 +43,7 @@ export type UNSTABLE_EntityStatusItem = {
* entry may apply to a different, newer version of the data than what is
* being returned in the catalog response.
*/
level: UNSTABLE_EntityStatusLevel;
level: EntityStatusLevel;
/**
* A brief message describing the status, intended for human consumption.
*/
@@ -58,7 +58,7 @@ export type UNSTABLE_EntityStatusItem = {
* Each entity status item has a level, describing its severity.
* @alpha
*/
export type UNSTABLE_EntityStatusLevel =
export type EntityStatusLevel =
| 'info' // Only informative data
| 'warning' // Warnings were found
| 'error'; // Errors were found
+4 -3
View File
@@ -21,6 +21,7 @@ export {
VIEW_URL_ANNOTATION,
} from './constants';
export type {
AlphaEntity,
Entity,
EntityLink,
EntityMeta,
@@ -29,9 +30,9 @@ export type {
} from './Entity';
export type { EntityEnvelope } from './EntityEnvelope';
export type {
UNSTABLE_EntityStatus,
UNSTABLE_EntityStatusItem,
UNSTABLE_EntityStatusLevel,
EntityStatus,
EntityStatusItem,
EntityStatusLevel,
} from './EntityStatus';
export * from './policies';
export {
+3 -3
View File
@@ -16,7 +16,7 @@
import lodash from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Entity } from './Entity';
import { Entity, AlphaEntity } from './Entity';
/**
* Generates a new random UID for an entity.
@@ -89,9 +89,9 @@ export function entityHasChanges(previous: Entity, next: Entity): boolean {
// Remove things that we explicitly do not compare
delete e1.relations;
delete e1.status;
delete (e1 as AlphaEntity).status;
delete e2.relations;
delete e2.status;
delete (e2 as AlphaEntity).status;
return !lodash.isEqual(e1, e2);
}
+8
View File
@@ -144,6 +144,14 @@
"nodemon": "^2.0.2",
"ts-node": "^10.0.0"
},
"peerDependencies": {
"@microsoft/api-extractor": "^7.19.2"
},
"peerDependenciesMeta": {
"@microsoft/api-extractor": {
"optional": true
}
},
"resolutions": {
"@types/webpack-dev-server/@types/webpack": "^5.28.0"
},
@@ -21,5 +21,6 @@ export default async (cmd: Command) => {
await buildPackage({
outputs: new Set([Output.cjs, Output.types]),
minify: cmd.minify,
useApiExtractor: cmd.experimentalTypeBuild,
});
};
+5 -1
View File
@@ -33,5 +33,9 @@ export default async (cmd: Command) => {
outputs = new Set([Output.types, Output.esm, Output.cjs]);
}
await buildPackage({ outputs, minify: cmd.minify });
await buildPackage({
outputs,
minify: cmd.minify,
useApiExtractor: cmd.experimentalTypeBuild,
});
};
+3
View File
@@ -48,6 +48,7 @@ export function registerCommands(program: CommanderStatic) {
.command('backend:build')
.description('Build a backend plugin')
.option('--minify', 'Minify the generated code')
.option('--experimental-type-build', 'Enable experimental type build')
.action(lazy(() => import('./backend/build').then(m => m.default)));
program
@@ -118,6 +119,7 @@ export function registerCommands(program: CommanderStatic) {
.command('plugin:build')
.description('Build a plugin')
.option('--minify', 'Minify the generated code')
.option('--experimental-type-build', 'Enable experimental type build')
.action(lazy(() => import('./plugin/build').then(m => m.default)));
program
@@ -139,6 +141,7 @@ export function registerCommands(program: CommanderStatic) {
.description('Build a package for publishing')
.option('--outputs <formats>', 'List of formats to output [types,cjs,esm]')
.option('--minify', 'Minify the generated code')
.option('--experimental-type-build', 'Enable experimental type build')
.action(lazy(() => import('./build').then(m => m.default)));
program
+38 -3
View File
@@ -16,12 +16,30 @@
import fs from 'fs-extra';
import { paths } from '../lib/paths';
import { join as joinPath } from 'path';
const SKIPPED_KEYS = ['access', 'registry', 'tag'];
const SKIPPED_KEYS = ['access', 'registry', 'tag', 'alphaTypes', 'betaTypes'];
const PKG_PATH = 'package.json';
const PKG_BACKUP_PATH = 'package.json-prepack';
// Writes e.g. alpha/package.json
async function writeReleaseStageEntrypoint(pkg: any, stage: 'alpha' | 'beta') {
await fs.ensureDir(paths.resolveTarget(stage));
await fs.writeJson(
paths.resolveTarget(stage, PKG_PATH),
{
name: pkg.name,
version: pkg.version,
main: (pkg.publishConfig.main || pkg.main) && '..',
module: (pkg.publishConfig.module || pkg.module) && '..',
browser: (pkg.publishConfig.browser || pkg.browser) && '..',
types: joinPath('..', pkg.publishConfig[`${stage}Types`]),
},
{ encoding: 'utf8', spaces: 2 },
);
}
export const pre = async () => {
const pkgPath = paths.resolveTarget(PKG_PATH);
@@ -29,18 +47,35 @@ export const pre = async () => {
const pkg = JSON.parse(pkgContent);
await fs.writeFile(PKG_BACKUP_PATH, pkgContent);
for (const key of Object.keys(pkg.publishConfig ?? {})) {
const publishConfig = pkg.publishConfig ?? {};
for (const key of Object.keys(publishConfig)) {
if (!SKIPPED_KEYS.includes(key)) {
pkg[key] = pkg.publishConfig[key];
pkg[key] = publishConfig[key];
}
}
await fs.writeJson(pkgPath, pkg, { encoding: 'utf8', spaces: 2 });
if (publishConfig.alphaTypes) {
await writeReleaseStageEntrypoint(pkg, 'alpha');
}
if (publishConfig.betaTypes) {
await writeReleaseStageEntrypoint(pkg, 'beta');
}
};
export const post = async () => {
// postpack isn't called by yarn right now, so it needs to be called manually
try {
await fs.move(PKG_BACKUP_PATH, PKG_PATH, { overwrite: true });
// Check if we're shipping types for other release stages, clean up in that case
const pkg = await fs.readJson(PKG_PATH);
if (pkg.publishConfig?.alphaTypes) {
await fs.remove(paths.resolveTarget('alpha'));
}
if (pkg.publishConfig?.betaTypes) {
await fs.remove(paths.resolveTarget('beta'));
}
} catch (error) {
console.warn(
`Failed to restore package.json during postpack, ${error}. ` +
@@ -21,5 +21,6 @@ export default async (cmd: Command) => {
await buildPackage({
outputs: new Set([Output.esm, Output.types]),
minify: cmd.minify,
useApiExtractor: cmd.experimentalTypeBuild,
});
};
@@ -0,0 +1,161 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import chalk from 'chalk';
import {
relative as relativePath,
resolve as resolvePath,
dirname,
} from 'path';
import { paths } from '../paths';
// These message types are ignored since we want to avoid duplicating the logic of
// handling them correctly, and we already have the API Reports warning about them.
const ignoredMessages = new Set(['tsdoc-undefined-tag', 'ae-forgotten-export']);
let apiExtractor: undefined | typeof import('@microsoft/api-extractor');
function prepareApiExtractor() {
if (apiExtractor) {
return apiExtractor;
}
try {
apiExtractor = require('@microsoft/api-extractor');
} catch (error) {
throw new Error(
'Failed to resolve @microsoft/api-extractor, it must best installed ' +
'as a dependency of your project in order to use experimental type builds',
);
}
/**
* All of this monkey patching below is because MUI has these bare package.json file as a method
* for making TypeScript accept imports like `@material-ui/core/Button`, and improve tree-shaking
* by declaring them side effect free.
*
* The package.json lookup logic in api-extractor really doesn't like that though, as it enforces
* that the 'name' field exists in all package.json files that it discovers. This below is just
* making sure that we ignore those file package.json files instead of crashing.
*/
const {
PackageJsonLookup,
// eslint-disable-next-line import/no-extraneous-dependencies
} = require('@rushstack/node-core-library/lib/PackageJsonLookup');
const old = PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor;
PackageJsonLookup.prototype.tryGetPackageJsonFilePathFor =
function tryGetPackageJsonFilePathForPatch(path: string) {
if (
path.includes('@material-ui') &&
!dirname(path).endsWith('@material-ui')
) {
return undefined;
}
return old.call(this, path);
};
return apiExtractor!;
}
export async function buildTypeDefinitions() {
const { Extractor, ExtractorConfig } = prepareApiExtractor();
const distTypesPackageDir = paths.resolveTargetRoot(
'dist-types',
relativePath(paths.targetRoot, paths.targetDir),
);
const entryPoint = resolvePath(distTypesPackageDir, 'src/index.d.ts');
const declarationsExist = await fs.pathExists(entryPoint);
if (!declarationsExist) {
const path = relativePath(paths.targetDir, entryPoint);
throw new Error(
`No declaration files found at ${path}, be sure to run ${chalk.bgRed.white(
'yarn tsc',
)} to generate .d.ts files before packaging`,
);
}
const extractorConfig = ExtractorConfig.prepare({
configObject: {
mainEntryPointFilePath: entryPoint,
bundledPackages: [],
compiler: {
skipLibCheck: true,
tsconfigFilePath: paths.resolveTargetRoot('tsconfig.json'),
},
dtsRollup: {
enabled: true,
untrimmedFilePath: paths.resolveTarget('dist/index.alpha.d.ts'),
betaTrimmedFilePath: paths.resolveTarget('dist/index.beta.d.ts'),
publicTrimmedFilePath: paths.resolveTarget('dist/index.d.ts'),
},
newlineKind: 'lf',
projectFolder: paths.targetDir,
},
configObjectFullPath: paths.targetDir,
packageJsonFullPath: paths.resolveTarget('package.json'),
});
const typescriptDir = paths.resolveTargetRoot('node_modules/typescript');
const hasTypescript = await fs.pathExists(typescriptDir);
const extractorResult = Extractor.invoke(extractorConfig, {
typescriptCompilerFolder: hasTypescript ? typescriptDir : undefined,
localBuild: false,
showVerboseMessages: false,
showDiagnostics: false,
messageCallback(message) {
message.handled = true;
if (ignoredMessages.has(message.messageId)) {
return;
}
let text = `${message.text} (${message.messageId})`;
if (message.sourceFilePath) {
text += ' at ';
text += relativePath(distTypesPackageDir, message.sourceFilePath);
if (message.sourceFileLine) {
text += `:${message.sourceFileLine}`;
if (message.sourceFileColumn) {
text += `:${message.sourceFileColumn}`;
}
}
}
if (message.logLevel === 'error') {
console.error(chalk.red(`Error: ${text}`));
} else if (
message.logLevel === 'warning' ||
message.category === 'Extractor'
) {
console.warn(`Warning: ${text}`);
} else {
console.log(text);
}
},
});
if (!extractorResult.succeeded) {
throw new Error(
`Type definition build completed with ${extractorResult.errorCount} errors` +
` and ${extractorResult.warningCount} warnings`,
);
}
}
+4 -4
View File
@@ -33,9 +33,9 @@ import { BuildOptions, Output } from './types';
import { paths } from '../paths';
import { svgrTemplate } from '../svgrTemplate';
export const makeConfigs = async (
export async function makeRollupConfigs(
options: BuildOptions,
): Promise<RollupOptions[]> => {
): Promise<RollupOptions[]> {
const configs = new Array<RollupOptions>();
if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) {
@@ -106,7 +106,7 @@ export const makeConfigs = async (
});
}
if (options.outputs.has(Output.types)) {
if (options.outputs.has(Output.types) && !options.useApiExtractor) {
const typesInput = paths.resolveTargetRoot(
'dist-types',
relativePath(paths.targetRoot, paths.targetDir),
@@ -134,4 +134,4 @@ export const makeConfigs = async (
}
return configs;
};
}
+14 -5
View File
@@ -19,8 +19,9 @@ import { rollup, RollupOptions } from 'rollup';
import chalk from 'chalk';
import { relative as relativePath } from 'path';
import { paths } from '../paths';
import { makeConfigs } from './config';
import { BuildOptions } from './types';
import { makeRollupConfigs } from './config';
import { BuildOptions, Output } from './types';
import { buildTypeDefinitions } from './buildTypeDefinitions';
export function formatErrorMessage(error: any) {
let msg = '';
@@ -71,7 +72,7 @@ export function formatErrorMessage(error: any) {
return msg;
}
async function build(config: RollupOptions) {
async function rollupBuild(config: RollupOptions) {
try {
const bundle = await rollup(config);
if (config.output) {
@@ -103,7 +104,15 @@ export const buildPackage = async (options: BuildOptions) => {
/* Errors ignored, this is just a warning */
}
const configs = await makeConfigs(options);
const rollupConfigs = await makeRollupConfigs(options);
await fs.remove(paths.resolveTarget('dist'));
await Promise.all(configs.map(build));
const buildTasks = rollupConfigs.map(rollupBuild);
if (options.outputs.has(Output.types) && options.useApiExtractor) {
buildTasks.push(buildTypeDefinitions());
}
await Promise.all(buildTasks);
};
+1
View File
@@ -23,4 +23,5 @@ export enum Output {
export type BuildOptions = {
outputs: Set<Output>;
minify?: boolean;
useApiExtractor?: boolean;
};
@@ -16,9 +16,9 @@
import { ENTITY_STATUS_CATALOG_PROCESSING_TYPE } from '@backstage/catalog-client';
import {
Entity,
AlphaEntity,
parseEntityRef,
UNSTABLE_EntityStatusItem,
EntityStatusItem,
} from '@backstage/catalog-model';
import { SerializedError, stringifyError } from '@backstage/errors';
import { Knex } from 'knex';
@@ -152,9 +152,9 @@ export class Stitcher {
// Grab the processed entity and stitch all of the relevant data into
// it
const entity = JSON.parse(processedEntity) as Entity;
const entity = JSON.parse(processedEntity) as AlphaEntity;
const isOrphan = Number(incomingReferenceCount) === 0;
let statusItems: UNSTABLE_EntityStatusItem[] = [];
let statusItems: EntityStatusItem[] = [];
if (isOrphan) {
this.logger.debug(`${entityRef} is an orphan`);
@@ -24,7 +24,7 @@ import {
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import React from 'react';
import { EntityProcessingErrorsPanel } from './EntityProcessingErrorsPanel';
import { Entity, getEntityName } from '@backstage/catalog-model';
import { AlphaEntity, getEntityName } from '@backstage/catalog-model';
import { ApiProvider } from '@backstage/core-app-api';
describe('<EntityProcessErrors />', () => {
@@ -34,7 +34,7 @@ describe('<EntityProcessErrors />', () => {
const apis = TestApiRegistry.from([catalogApiRef, { getEntityAncestors }]);
it('renders EntityProcessErrors if the entity has errors', async () => {
const entity: Entity = {
const entity: AlphaEntity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
@@ -122,7 +122,7 @@ describe('<EntityProcessErrors />', () => {
});
it('renders EntityProcessErrors if the parent entity has errors', async () => {
const entity: Entity = {
const entity: AlphaEntity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
@@ -136,7 +136,7 @@ describe('<EntityProcessErrors />', () => {
},
};
const parent: Entity = {
const parent: AlphaEntity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
@@ -16,8 +16,9 @@
import {
Entity,
AlphaEntity,
stringifyEntityRef,
UNSTABLE_EntityStatusItem,
EntityStatusItem,
compareEntityToRef,
} from '@backstage/catalog-model';
import {
@@ -36,7 +37,7 @@ import { useApi, ApiHolder } from '@backstage/core-plugin-api';
import useAsync from 'react-use/lib/useAsync';
import { SerializedError } from '@backstage/errors';
const errorFilter = (i: UNSTABLE_EntityStatusItem) =>
const errorFilter = (i: EntityStatusItem) =>
i.error &&
i.level === 'error' &&
i.type === ENTITY_STATUS_CATALOG_PROCESSING_TYPE;
@@ -55,7 +56,7 @@ async function getOwnAndAncestorsErrors(
const ancestors = await catalogApi.getEntityAncestors({ entityRef });
const items = ancestors.items
.map(item => {
const statuses = item.entity.status?.items ?? [];
const statuses = (item.entity as AlphaEntity).status?.items ?? [];
const errors = statuses
.filter(errorFilter)
.map(e => e.error)