Added step to backstage-cli repo fix --publish command

Added a fixer step to the backstage-cli fix command that will annotate default export features to the package json backstage metadata

Signed-off-by: Harrison Hogg <hhogg@spotify.com>
This commit is contained in:
Harrison Hogg
2024-08-16 16:20:03 +01:00
parent 3e0f04a58b
commit 520a383e5e
68 changed files with 1242 additions and 37 deletions
+1
View File
@@ -147,6 +147,7 @@
"swc-loader": "^0.2.3",
"tar": "^6.1.12",
"terser-webpack-plugin": "^5.1.3",
"ts-morph": "^23.0.0",
"util": "^0.12.3",
"webpack": "^5.70.0",
"webpack-dev-server": "^5.0.0",
+52 -2
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { findPaths } from '@backstage/cli-common';
import {
BackstagePackage,
BackstagePackageJson,
@@ -23,9 +24,13 @@ import {
} from '@backstage/cli-node';
import { OptionValues } from 'commander';
import fs from 'fs-extra';
import isEqual from 'lodash/isEqual';
import { resolve as resolvePath, posix, relative as relativePath } from 'path';
import { Project } from 'ts-morph';
import { paths } from '../../lib/paths';
import { publishPreflightCheck } from '../../lib/publishing';
import { getFeaturesMetadata } from '../../lib/features';
import { readEntryPoints } from '../../lib/entryPoints';
/**
* A mutable object representing a package.json file with potential fixes.
@@ -426,7 +431,46 @@ export function fixPluginPackages(
}
}
type PackageFixer = (pkg: FixablePackage, packages: FixablePackage[]) => void;
// For each of the defined export locations, we want to annotate
// the backstage field with the exported system components. The
// "exports" field in package.json is a mapping of import paths to file paths.
//
// While the exports field is really flexible, this function will enforce a
// particular structure, which is a Record<string, string> where the key is the
// import path and the value is the file path.
export function fixPluginFeatures(
pkg: FixablePackage,
_packages: FixablePackage[],
project: Project,
) {
const { dir, packageJson } = pkg;
if (
!packageJson.backstage ||
!packageJson.backstage.role ||
!packageJson.exports
) {
return;
}
const entryPoints = readEntryPoints(packageJson);
const { role } = packageJson.backstage;
const featuresMetadata = getFeaturesMetadata(project, role, dir, entryPoints);
if (
featuresMetadata.length &&
!isEqual(packageJson.backstage.features, featuresMetadata)
) {
packageJson.backstage.features = featuresMetadata;
pkg.changed = true;
}
}
type PackageFixer = (
pkg: FixablePackage,
packages: FixablePackage[],
project: Project,
) => void;
export async function command(opts: OptionValues): Promise<void> {
const packages = await readFixablePackages();
@@ -440,14 +484,20 @@ export async function command(opts: OptionValues): Promise<void> {
fixRepositoryField,
fixPluginId,
fixPluginPackages,
fixPluginFeatures,
// Run the publish preflight check too, to make sure we don't uncover errors during publishing
publishPreflightCheck,
);
}
const workspaceRoot = findPaths(process.cwd()).targetRoot;
const project = new Project({
tsConfigFilePath: resolvePath(workspaceRoot, 'tsconfig.json'),
});
for (const fixer of fixers) {
for (const pkg of packages) {
fixer(pkg, packages);
fixer(pkg, packages, project);
}
}
+273
View File
@@ -0,0 +1,273 @@
/*
* Copyright 2024 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 { BackstagePackageFeatureType, PackageRole } from '@backstage/cli-node';
import createFeatureEnvironment from '../tests/createFeatureEnvironment';
import { getEntryPointExports, getFeaturesMetadata } from './features';
describe('features', () => {
describe('for package role', () => {
// This Record makes sure we're checking all package roles
const packageRoles: Record<PackageRole, boolean> = {
// Allowed
'backend-plugin': true,
'backend-plugin-module': true,
'frontend-plugin': true,
'frontend-plugin-module': true,
// Disallowed
frontend: false,
backend: false,
cli: false,
'web-library': false,
'node-library': false,
'common-library': false,
};
const allowedPackageRoles = Object.keys(packageRoles).filter(
role => packageRoles[role as PackageRole],
);
const disallowedPackageRoles = Object.keys(packageRoles).filter(
role => !packageRoles[role as PackageRole],
);
it.each(allowedPackageRoles)(`returns features for %s`, r => {
const { project, role, dir, entryPoints } = createFeatureEnvironment({
role: r as PackageRole,
});
expect(getFeaturesMetadata(project, role, dir, entryPoints)).toEqual([
{
type: '@backstage/BackendFeature',
},
]);
});
it.each(disallowedPackageRoles)(`does not return features for %s`, r => {
const { project, role, dir, entryPoints } = createFeatureEnvironment({
role: r as PackageRole,
});
expect(getFeaturesMetadata(project, role, dir, entryPoints)).toEqual([]);
});
});
describe('for feature $$type', () => {
// This Record makes sure we're checking all feature types
const featureTypes: Record<BackstagePackageFeatureType, boolean> = {
// Allowed
'@backstage/BackendFeature': true,
'@backstage/BackstagePlugin': true,
'@backstage/FrontendPlugin': true,
'@backstage/FrontendModule': true,
// Disallowed
'@backstage/BackendFeatureFactory': false,
'@backstage/BackstageCredentials': false,
'@backstage/Extension': false,
'@backstage/ExtensionDataRef': false,
'@backstage/ExtensionDataValue': false,
'@backstage/ExtensionDefinition': false,
'@backstage/ExtensionInput': false,
'@backstage/ExtensionOverrides': false,
'@backstage/ExtensionPoint': false,
'@backstage/ExternalRouteRef': false,
'@backstage/RouteRef': false,
'@backstage/ServiceRef': false,
'@backstage/SubRouteRef': false,
'@backstage/TranslationMessages': false,
'@backstage/TranslationRef': false,
'@backstage/TranslationResource': false,
};
const allowedFeatureTypes = Object.keys(featureTypes).filter(
$$type => featureTypes[$$type as BackstagePackageFeatureType],
);
const disallowedFeatureTypes = Object.keys(featureTypes).filter(
$$type => !featureTypes[$$type as BackstagePackageFeatureType],
);
it.each(allowedFeatureTypes)(`returns features for "%s" $$type`, $$type => {
const { project, role, dir, entryPoints } = createFeatureEnvironment({
$$type: $$type as BackstagePackageFeatureType,
});
expect(getFeaturesMetadata(project, role, dir, entryPoints)).toEqual([
{
type: $$type,
},
]);
});
it.each(disallowedFeatureTypes)(
`does not return features for "%s" $$type`,
$$type => {
const { project, role, dir, entryPoints } = createFeatureEnvironment({
$$type: $$type as BackstagePackageFeatureType,
});
expect(getFeaturesMetadata(project, role, dir, entryPoints)).toEqual(
[],
);
},
);
});
describe('export declaration formats', () => {
it('supports format of "export default ..."', () => {
const {
project,
dir,
entryPoints: [entryPoint],
} = createFeatureEnvironment({
format: 'DefaultExport',
});
expect(getEntryPointExports(entryPoint, project, dir)).toEqual([
{
location: '.',
name: 'default',
type: '@backstage/BackendFeature',
},
]);
});
it('supports format of "export { default } from ..."', () => {
const {
project,
dir,
entryPoints: [entryPoint],
} = createFeatureEnvironment({
format: 'DefaultExportLinked',
});
expect(getEntryPointExports(entryPoint, project, dir)).toEqual([
{
location: '.',
name: 'default',
type: '@backstage/BackendFeature',
},
]);
});
it('supports format of "export { default, ... } from ..."', () => {
const {
project,
dir,
entryPoints: [entryPoint],
} = createFeatureEnvironment({
format: 'DefaultExportLinkedWithSibling',
});
expect(getEntryPointExports(entryPoint, project, dir)).toEqual([
{
location: '.',
name: 'default',
type: '@backstage/BackendFeature',
},
{
location: '.',
name: 'test',
type: '@backstage/BackendFeature',
},
]);
});
it('supports format of "export const foo = ..."', () => {
const {
project,
dir,
entryPoints: [entryPoint],
} = createFeatureEnvironment({
format: 'NamedExport',
});
expect(getEntryPointExports(entryPoint, project, dir)).toEqual([
{
location: '.',
name: 'test',
type: '@backstage/BackendFeature',
},
]);
});
it('supports format of "export * from ..."', () => {
const {
project,
dir,
entryPoints: [entryPoint],
} = createFeatureEnvironment({
format: 'WildCardExport',
});
expect(getEntryPointExports(entryPoint, project, dir)).toEqual([
{
location: '.',
name: 'test',
type: '@backstage/BackendFeature',
},
]);
});
});
describe('entry points', () => {
it('returns features for multiple entry points', () => {
const { project, role, dir, entryPoints } = createFeatureEnvironment({
exports: {
'.': 'src/index.ts',
'./alpha': 'src/alpha.ts',
'./beta': 'src/beta.ts',
},
});
expect(getFeaturesMetadata(project, role, dir, entryPoints)).toEqual([
{
type: '@backstage/BackendFeature',
},
{
path: './alpha',
type: '@backstage/BackendFeature',
},
{
path: './beta',
type: '@backstage/BackendFeature',
},
]);
});
it('ignores entry points that are not .ts or .tsx files', () => {
const { project, role, dir, entryPoints } = createFeatureEnvironment({
exports: {
'.': 'src/index.js',
},
});
expect(getFeaturesMetadata(project, role, dir, entryPoints)).toEqual([]);
});
});
it('only returns default exports', () => {
const { project, role, dir, entryPoints } = createFeatureEnvironment({
format: 'DefaultExportLinkedWithSibling',
});
expect(getFeaturesMetadata(project, role, dir, entryPoints)).toEqual([
{
type: '@backstage/BackendFeature',
},
]);
});
});
+163
View File
@@ -0,0 +1,163 @@
/*
* 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 {
isValidPackageFeatureType,
PackageRole,
type BackstagePackageFeature,
type BackstagePackageFeatureType,
} from '@backstage/cli-node';
import { Project, SyntaxKind, ts, Type } from 'ts-morph';
import { resolve as resolvePath } from 'node:path';
import { EntryPoint } from './entryPoints';
// A list of the package roles we want to extract features for
const targetPackageRoles: PackageRole[] = [
'backend-plugin',
'backend-plugin-module',
'frontend-plugin',
'frontend-plugin-module',
];
// A list of the feature types we want to extract from the project
// and include in the metadata
const targetFeatureTypes: BackstagePackageFeatureType[] = [
'@backstage/BackendFeature',
'@backstage/BackstagePlugin',
'@backstage/FrontendPlugin',
'@backstage/FrontendModule',
];
// Returns all valid Backstage package features from a project
// for all entry points.
export function getFeaturesMetadata(
project: Project,
role: PackageRole,
dir: string,
entryPoints: EntryPoint[],
): BackstagePackageFeature[] {
if (!targetPackageRoles.includes(role)) {
return [];
}
return entryPoints
.flatMap(entryPoint => getEntryPointExports(entryPoint, project, dir))
.filter(isDefaultExport)
.filter(isTargetFeatureType)
.map(toBackstagePackageFeature);
}
type EntryPointExport = {
name: string;
location: string;
type: BackstagePackageFeatureType;
};
// Returns all exports (default and named) from an entry point
// that are valid Backstage package features
export function getEntryPointExports(
{ mount: location, path, ext }: EntryPoint,
project: Project,
dir: string,
): EntryPointExport[] {
const fullFilePath = resolvePath(dir, path);
const sourceFile = project.getSourceFile(fullFilePath);
if (!sourceFile || (ext !== '.ts' && ext !== '.tsx')) {
return [];
}
const exports = [];
for (const exportSymbol of sourceFile.getExportSymbols()) {
const declaration = exportSymbol.getDeclarations()[0];
const exportName = declaration.getSymbol()?.getName();
let exportType: Type<ts.Type> | undefined;
if (declaration) {
if (declaration.isKind(SyntaxKind.ExportAssignment)) {
exportType = declaration.getExpression().getType();
} else if (declaration.isKind(SyntaxKind.ExportSpecifier)) {
if (!declaration.isTypeOnly()) {
exportType = declaration.getType();
}
} else if (declaration.isKind(SyntaxKind.VariableDeclaration)) {
exportType = declaration.getType();
}
}
if (exportName && exportType) {
const $$type = getBackstagePackageFeature$$TypeFromType(exportType);
if ($$type) {
exports.push({
name: exportName,
location,
type: $$type,
});
}
}
}
return exports;
}
// Given a TS type, returns the Backstage package feature $$type value
function getBackstagePackageFeature$$TypeFromType(
type: Type,
): BackstagePackageFeatureType | null {
// Returns the concrete type of a generic type
const exportType = type.getTargetType() ?? type;
for (const property of exportType.getProperties()) {
if (property.getName() === '$$type') {
const $$type = property
.getValueDeclaration()
?.getText()
.match(/(?<type>@backstage\/\w+)/)?.groups?.type;
if ($$type && isValidPackageFeatureType($$type)) {
return $$type;
}
}
}
return null;
}
// Returns whether an export is the default export
function isDefaultExport({ name }: EntryPointExport): boolean {
return name === 'default';
}
// Returns whether an export is a valid Backstage package feature type
function isTargetFeatureType({ type }: EntryPointExport): boolean {
return targetFeatureTypes.includes(type);
}
// Converts an entry point export to a Backstage package feature
function toBackstagePackageFeature({
type,
location,
}: EntryPointExport): BackstagePackageFeature {
const feature: BackstagePackageFeature = { type };
if (location !== '.') {
feature.path = location;
}
return feature;
}
@@ -0,0 +1,196 @@
/*
* Copyright 2024 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 { BackstagePackageFeatureType, PackageRole } from '@backstage/cli-node';
import path from 'path';
import { Project } from 'ts-morph';
import { EntryPoint } from '../lib/entryPoints';
type CreateFeatureEnvironmentOptions = {
$$type?: BackstagePackageFeatureType;
exports?: Record<string, string>;
format?:
| 'DefaultExport'
| 'DefaultExportLinked'
| 'DefaultExportLinkedWithSibling'
| 'NamedExport'
| 'WildCardExport';
role?: PackageRole;
};
type FeatureEnvironment = {
project: Project;
role: PackageRole;
dir: string;
entryPoints: EntryPoint[];
};
type File = {
path: string;
content: string;
};
const createForExports = (
exports: Record<string, string>,
content: string,
): File[] => {
return Object.entries(exports).map(([_, filePath]) => ({
path: `./${filePath}`,
content,
}));
};
const createTestType = ($$type: BackstagePackageFeatureType): File[] => [
{
path: './src/createTestType.ts',
content: `
export interface TestType {
readonly $$type: '${$$type}';
};
export function createTestType(): TestType {
return {
$$type: '${$$type}',
};
};
`,
},
];
const createTestDefaultExport = (exports: Record<string, string>): File[] =>
createForExports(
exports,
`
import { createTestType } from './createTestType';
export { TestType } from './createTestType';
export default createTestType();
`,
);
const createTestDefaultExportLinked = (
exports: Record<string, string>,
): File[] => [
...createForExports(
exports,
`
export { TestType } from './createTestType';
export { default } from './linked';
`,
),
{
path: './src/linked.ts',
content: `
import { createTestType } from './createTestType';
export default createTestType();
`,
},
];
const createTestDefaultExportLinkedWithSibling = (
exports: Record<string, string>,
): File[] => [
...createForExports(
exports,
`
export { TestType } from './createTestType';
export { default, test } from './linked';
`,
),
{
path: './src/linked.ts',
content: `
import { createTestType } from './createTestType';
export const test = createTestType();
export default createTestType();
`,
},
];
const createTestNamedExport = (exports: Record<string, string>): File[] => [
...createForExports(
exports,
`
import { createTestType } from './createTestType';
export { TestType } from './createTestType';
export const test = createTestType();
`,
),
];
const createTestWildCardExport = (exports: Record<string, string>): File[] => [
...createForExports(
exports,
`
export * from './linked';
`,
),
{
path: './src/linked.ts',
content: `
import { createTestType } from './createTestType';
export { TestType } from './createTestType';
export const test = createTestType();
export default createTestType();
`,
},
];
const formatToFiles = {
DefaultExport: createTestDefaultExport,
DefaultExportLinked: createTestDefaultExportLinked,
DefaultExportLinkedWithSibling: createTestDefaultExportLinkedWithSibling,
NamedExport: createTestNamedExport,
WildCardExport: createTestWildCardExport,
};
export default function createFeatureEnvironment(
options?: CreateFeatureEnvironmentOptions,
): FeatureEnvironment {
const {
$$type = '@backstage/BackendFeature',
exports = { '.': 'src/index.ts' },
format = 'DefaultExport',
role = 'backend-plugin',
} = options ?? {};
const project = new Project();
const entryPoints: EntryPoint[] = Object.entries(exports ?? {}).map(
([mount, filePath]) => ({
mount,
path: filePath,
name: mount,
ext: path.extname(filePath),
}),
);
const files = [...createTestType($$type), ...formatToFiles[format](exports)];
for (const file of files) {
project.createSourceFile(file.path, file.content);
}
return {
project,
role,
dir: project.getFileSystem().getCurrentDirectory(),
entryPoints,
};
}