From c837e6d7d89b5be75c1961412592eca67901f442 Mon Sep 17 00:00:00 2001 From: Harrison Hogg Date: Thu, 12 Sep 2024 14:16:20 +0100 Subject: [PATCH] Change default feature export to happen in publish script Signed-off-by: Harrison Hogg --- .../cli-node/src/monorepo/PackageGraph.ts | 17 -- packages/cli-node/src/monorepo/index.ts | 1 - packages/cli/src/commands/repo/fix.ts | 54 +--- .../__testUtils__/createFeatureEnvironment.ts | 155 ++++++++++ packages/cli/src/lib/features.test.ts | 273 ------------------ packages/cli/src/lib/features.ts | 165 ----------- .../cli/src/lib/packager/productionPack.ts | 20 ++ packages/cli/src/lib/typeDistProject.test.ts | 141 +++++++++ packages/cli/src/lib/typeDistProject.ts | 240 +++++++++++++++ .../cli/src/tests/createFeatureEnvironment.ts | 196 ------------- 10 files changed, 558 insertions(+), 704 deletions(-) create mode 100644 packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts delete mode 100644 packages/cli/src/lib/features.test.ts delete mode 100644 packages/cli/src/lib/features.ts create mode 100644 packages/cli/src/lib/typeDistProject.test.ts create mode 100644 packages/cli/src/lib/typeDistProject.ts delete mode 100644 packages/cli/src/tests/createFeatureEnvironment.ts diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index 374be3add7..0394fd34ca 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -68,18 +68,6 @@ export const isValidPackageFeatureType = ( */ export type BackstagePackageFeatureType = (typeof packageFeatureTypes)[number]; -/** - * Metadata relating to an exported Backstage system component - * - * @public - */ -export type BackstagePackageFeature = { - // The export path, if omitted then it is the default package export - path?: string; - // The type of the export - type: BackstagePackageFeatureType; -}; - /** * Known fields in Backstage package.json files. * @@ -128,11 +116,6 @@ export interface BackstagePackageJson { * All packages that are part of the plugin. Must always and only be set for plugin packages and plugin library packages. */ pluginPackages?: string[]; - - /** - * The Backstage system components exported from this package - */ - features?: BackstagePackageFeature[]; }; exports?: JsonValue; diff --git a/packages/cli-node/src/monorepo/index.ts b/packages/cli-node/src/monorepo/index.ts index b0e595077d..b4ed921613 100644 --- a/packages/cli-node/src/monorepo/index.ts +++ b/packages/cli-node/src/monorepo/index.ts @@ -22,7 +22,6 @@ export { type PackageGraphNode, type BackstagePackage, type BackstagePackageJson, - type BackstagePackageFeature, type BackstagePackageFeatureType, } from './PackageGraph'; export { diff --git a/packages/cli/src/commands/repo/fix.ts b/packages/cli/src/commands/repo/fix.ts index 68b07564fb..a55440c53f 100644 --- a/packages/cli/src/commands/repo/fix.ts +++ b/packages/cli/src/commands/repo/fix.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { findPaths } from '@backstage/cli-common'; import { BackstagePackage, BackstagePackageJson, @@ -24,13 +23,9 @@ 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. @@ -431,46 +426,7 @@ export function fixPluginPackages( } } -// 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 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; +type PackageFixer = (pkg: FixablePackage, packages: FixablePackage[]) => void; export async function command(opts: OptionValues): Promise { const packages = await readFixablePackages(); @@ -484,20 +440,14 @@ export async function command(opts: OptionValues): Promise { 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, project); + fixer(pkg, packages); } } diff --git a/packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts b/packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts new file mode 100644 index 0000000000..7d7eeeeeaf --- /dev/null +++ b/packages/cli/src/lib/__testUtils__/createFeatureEnvironment.ts @@ -0,0 +1,155 @@ +/* + * 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 { resolve as resolvePath } from 'path'; +import { Project } from 'ts-morph'; +import { EntryPoint } from '../entryPoints'; +import { getDistTypeRoot } from '../typeDistProject'; + +const mockEntryPoint = { + mount: '.', + path: './src/index.d.ts', + name: 'index', + ext: '.d.ts', +}; + +type CreateFeatureEnvironmentOptions = { + $$type?: BackstagePackageFeatureType; + format?: + | 'DefaultExportAssignment' + | 'DefaultExportFromFile' + | 'DefaultExportFromFileAsDefault' + | 'DefaultExportFromFileWithSibling'; + role?: PackageRole; +}; + +type FeatureEnvironment = { + project: Project; + role: PackageRole; + dir: string; + entryPoint: EntryPoint; +}; + +type File = { + path: string; + content: string; +}; + +const createTestType = ($$type: BackstagePackageFeatureType): File[] => [ + { + path: './src/createTestType.d.ts', + content: ` +export interface TestType { + readonly $$type: '${$$type}'; +}; + +export function createTestType(): TestType { + return { + $$type: '${$$type}', + }; +}; + `, + }, +]; + +const createMockDefaultExportAssignment = (): File[] => [ + { + path: mockEntryPoint.path, + content: ` +declare const _default: import("./createTestType").TestType; +export default _default; + `, + }, +]; + +const createMockDefaultExportFromFile = (): File[] => [ + { + path: mockEntryPoint.path, + content: `export { default } from './linked';`, + }, + { + path: './src/linked.d.ts', + content: ` +declare const _default: import("./createTestType").TestType; +export default _default; +`, + }, +]; + +const createMockDefaultExportFromFileAsDefault = (): File[] => [ + { + path: mockEntryPoint.path, + content: `export { test as default } from './linked';`, + }, + { + path: './src/linked.d.ts', + content: ` +export declare const test: import("./createTestType").TestType; + `, + }, +]; + +const createMockDefaultExportFromFileWithSibling = (): File[] => [ + { + path: mockEntryPoint.path, + content: `export { default, test } from './linked';`, + }, + { + path: './src/linked.d.ts', + content: ` +import { createTestType } from './createTestType'; + +export declare const test: import("./createTestType").TestType; +declare const _default: import("./createTestType").TestType; +export default _default; + `, + }, +]; + +const formatToFiles = { + DefaultExportAssignment: createMockDefaultExportAssignment, + DefaultExportFromFile: createMockDefaultExportFromFile, + DefaultExportFromFileAsDefault: createMockDefaultExportFromFileAsDefault, + DefaultExportFromFileWithSibling: createMockDefaultExportFromFileWithSibling, +}; + +export default function createFeatureEnvironment( + options?: CreateFeatureEnvironmentOptions, +): FeatureEnvironment { + const { + $$type = '@backstage/BackendFeature', + format = 'DefaultExportAssignment', + role = 'backend-plugin', + } = options ?? {}; + + const project = new Project(); + const files = [...createTestType($$type), ...formatToFiles[format]()]; + + for (const file of files) { + project.createSourceFile( + resolvePath(getDistTypeRoot(''), file.path), + file.content, + ); + } + + return { + project, + role, + dir: project.getFileSystem().getCurrentDirectory(), + entryPoint: mockEntryPoint, + }; +} diff --git a/packages/cli/src/lib/features.test.ts b/packages/cli/src/lib/features.test.ts deleted file mode 100644 index 43b9a8923f..0000000000 --- a/packages/cli/src/lib/features.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -/* - * 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 = { - // Allowed - 'backend-plugin': true, - 'backend-plugin-module': true, - 'frontend-plugin': true, - 'frontend-plugin-module': true, - 'web-library': true, - 'node-library': true, - // Disallowed - frontend: false, - backend: false, - cli: 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 = { - // 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', - }, - ]); - }); -}); diff --git a/packages/cli/src/lib/features.ts b/packages/cli/src/lib/features.ts deleted file mode 100644 index fd1b35d972..0000000000 --- a/packages/cli/src/lib/features.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * 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', - 'web-library', - 'node-library', -]; - -// 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 | 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(/(?@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; -} diff --git a/packages/cli/src/lib/packager/productionPack.ts b/packages/cli/src/lib/packager/productionPack.ts index 3c2b5aa89e..e4a1c13ffd 100644 --- a/packages/cli/src/lib/packager/productionPack.ts +++ b/packages/cli/src/lib/packager/productionPack.ts @@ -19,6 +19,10 @@ import npmPackList from 'npm-packlist'; import { resolve as resolvePath, posix as posixPath } from 'path'; import { BackstagePackageJson } from '@backstage/cli-node'; import { readEntryPoints } from '../entryPoints'; +import { + createTypeDistProject, + getEntryPointDefaultFeatureType, +} from '../typeDistProject'; const PKG_PATH = 'package.json'; const PKG_BACKUP_PATH = 'package.json-prepack'; @@ -147,11 +151,14 @@ async function prepareExportsEntryPoints( >(); const entryPoints = readEntryPoints(pkg); + const project = await createTypeDistProject(); + for (const entryPoint of entryPoints) { if (!SCRIPT_EXTS.includes(entryPoint.ext)) { outputExports[entryPoint.mount] = entryPoint.path; continue; } + const exp = {} as Record; for (const [key, ext] of Object.entries(EXPORT_MAP)) { const name = `${entryPoint.name}${ext}`; @@ -161,6 +168,19 @@ async function prepareExportsEntryPoints( } exp.default = exp.require ?? exp.import; + const defaultFeatureType = + pkg.backstage?.role && + getEntryPointDefaultFeatureType( + pkg.backstage?.role, + packageDir, + project, + entryPoint, + ); + + if (defaultFeatureType) { + exp.backstage = defaultFeatureType; + } + // This creates a directory with a lone package.json for backwards compatibility if (entryPoint.mount === '.') { if (exp.default) { diff --git a/packages/cli/src/lib/typeDistProject.test.ts b/packages/cli/src/lib/typeDistProject.test.ts new file mode 100644 index 0000000000..f3168dd9be --- /dev/null +++ b/packages/cli/src/lib/typeDistProject.test.ts @@ -0,0 +1,141 @@ +/* + * 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 './__testUtils__/createFeatureEnvironment'; +import { getEntryPointDefaultFeatureType } from './typeDistProject'; + +describe('typeDistProject', () => { + describe('for package role', () => { + // This Record makes sure we're checking all package roles + const packageRoles: Record = { + // Allowed + 'backend-plugin': true, + 'backend-plugin-module': true, + 'frontend-plugin': true, + 'frontend-plugin-module': true, + 'web-library': true, + 'node-library': true, + // Disallowed + frontend: false, + backend: false, + cli: 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, entryPoint } = createFeatureEnvironment({ + role: r as PackageRole, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual('@backstage/BackendFeature'); + }); + + it.each(disallowedPackageRoles)(`does not return features for %s`, r => { + const { project, role, dir, entryPoint } = createFeatureEnvironment({ + role: r as PackageRole, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual(null); + }); + }); + + describe('for feature $$type', () => { + // This Record makes sure we're checking all feature types + const featureTypes: Record = { + // 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, entryPoint } = createFeatureEnvironment({ + $$type: $$type as BackstagePackageFeatureType, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual($$type); + }); + + it.each(disallowedFeatureTypes)( + `does not return features for "%s" $$type`, + $$type => { + const { project, role, dir, entryPoint } = createFeatureEnvironment({ + $$type: $$type as BackstagePackageFeatureType, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual(null); + }, + ); + }); + + it.each([ + 'DefaultExportAssignment', + 'DefaultExportFromFile', + 'DefaultExportFromFileAsDefault', + 'DefaultExportFromFileWithSibling', + ] as const)('returns features for format "%s"', format => { + const { project, role, dir, entryPoint } = createFeatureEnvironment({ + format, + }); + + expect( + getEntryPointDefaultFeatureType(role, dir, project, entryPoint), + ).toEqual('@backstage/BackendFeature'); + }); +}); diff --git a/packages/cli/src/lib/typeDistProject.ts b/packages/cli/src/lib/typeDistProject.ts new file mode 100644 index 0000000000..4fa970957d --- /dev/null +++ b/packages/cli/src/lib/typeDistProject.ts @@ -0,0 +1,240 @@ +/* + * 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 { findPaths } from '@backstage/cli-common'; +import { + BackstagePackageFeatureType, + isValidPackageFeatureType, + PackageGraph, + PackageRole, +} from '@backstage/cli-node'; +import { builtinModules } from 'module'; +import { resolve as resolvePath } from 'path'; +import { Project, SourceFile, SyntaxKind, ts, Type } from 'ts-morph'; +import { EntryPoint, readEntryPoints } from './entryPoints'; + +export const getDistTypeRoot = (dir: string) => { + const workspaceRoot = findPaths(process.cwd()).targetRoot; + const relativeDirectory = dir.replace(workspaceRoot, ''); + const distTypeRoot = resolvePath( + workspaceRoot, + `./dist-types${relativeDirectory}`, + ); + + return distTypeRoot; +}; + +const getPackagesDistTypeMap = async () => { + const packages = await PackageGraph.listTargetPackages(); + const distTypeMap: Record = {}; + + for (const { dir, packageJson } of packages) { + const distTypeRoot = getDistTypeRoot(dir); + + for (const { name, path, ext } of readEntryPoints(packageJson)) { + const dtsPath = resolvePath(distTypeRoot, path.replace(ext, '.d.ts')); + + if (name === 'index') { + distTypeMap[packageJson.name] = dtsPath; + } else { + distTypeMap[`${packageJson.name}/${name}`] = dtsPath; + } + } + } + + return distTypeMap; +}; + +export const createTypeDistProject = async () => { + const distTypeMap = await getPackagesDistTypeMap(); + const workspaceRoot = findPaths(process.cwd()).targetRoot; + + return new Project({ + tsConfigFilePath: resolvePath(workspaceRoot, 'tsconfig.json'), + skipAddingFilesFromTsConfig: true, + resolutionHost: (moduleResolutionHost, getCompilerOptions) => { + return { + resolveModuleNames: (moduleNames, containingFile) => { + const compilerOptions = getCompilerOptions(); + const resolvedModules: ts.ResolvedModule[] = []; + + for (let moduleName of moduleNames) { + // Handle resolve internal plugins and package entry points to dist-types folder + if (distTypeMap[moduleName]) { + resolvedModules.push({ + resolvedFileName: distTypeMap[moduleName], + isExternalLibraryImport: false, + resolvedUsingTsExtension: false, + }); + continue; + } + + // Handle resolving builtin node modules to @types/node + if (moduleName.startsWith('node:')) { + moduleName = moduleName.slice(5); + } + if (builtinModules.includes(moduleName)) { + const result = ts.resolveModuleName( + `@types/node/${moduleName}`, + containingFile, + compilerOptions, + moduleResolutionHost, + ); + + if (result.resolvedModule) { + resolvedModules.push(result.resolvedModule); + continue; + } + } + + // Handle resolve relative paths and external node_modules. + const result = ts.resolveModuleName( + moduleName, + containingFile, + compilerOptions, + moduleResolutionHost, + ); + + if (result.resolvedModule) { + resolvedModules.push(result.resolvedModule); + continue; + } + + throw new Error(`Failed to resolve module: ${moduleName}`); + } + + return resolvedModules; + }, + }; + }, + }); +}; + +// 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', + 'web-library', + 'node-library', +]; + +// 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', +]; + +export const getEntryPointDefaultFeatureType = ( + role: PackageRole, + packageDir: string, + project: Project, + entryPoint: EntryPoint, +): BackstagePackageFeatureType | null => { + if (isTargetPackageRole(role)) { + const dtsPath = resolvePath( + getDistTypeRoot(packageDir), + entryPoint.path.replace(entryPoint.ext, '.d.ts'), + ); + + const defaultFeatureType = getSourceFileDefaultFeatureType( + project.addSourceFileAtPath(dtsPath), + ); + + if (isTargetFeatureType(defaultFeatureType)) { + return defaultFeatureType; + } + } + + return null; +}; + +// Condition for a package role matches a target package role +function isTargetPackageRole(role: PackageRole): boolean { + return !!role && targetPackageRoles.includes(role); +} + +// Returns whether an export is a valid Backstage package feature type +function isTargetFeatureType( + type: BackstagePackageFeatureType | null, +): boolean { + return !!type && targetFeatureTypes.includes(type); +} + +// Returns all exports (default and named) from an entry point +// that are valid Backstage package features +function getSourceFileDefaultFeatureType( + sourceFile: SourceFile, +): BackstagePackageFeatureType | null { + for (const exportSymbol of sourceFile.getExportSymbols()) { + const declaration = exportSymbol.getDeclarations()[0]; + const exportName = declaration.getSymbol()?.getName(); + + if (exportName !== 'default') { + continue; + } + + let exportType: 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) { + return $$type; + } + } + } + + return null; +} + +// 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(/(?@backstage\/\w+)/)?.groups?.type; + + if ($$type && isValidPackageFeatureType($$type)) { + return $$type; + } + } + } + + return null; +} diff --git a/packages/cli/src/tests/createFeatureEnvironment.ts b/packages/cli/src/tests/createFeatureEnvironment.ts deleted file mode 100644 index 6313e6439b..0000000000 --- a/packages/cli/src/tests/createFeatureEnvironment.ts +++ /dev/null @@ -1,196 +0,0 @@ -/* - * 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; - 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, - 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): File[] => - createForExports( - exports, - ` -import { createTestType } from './createTestType'; - -export { TestType } from './createTestType'; -export default createTestType(); - `, - ); - -const createTestDefaultExportLinked = ( - exports: Record, -): 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, -): 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): File[] => [ - ...createForExports( - exports, - ` -import { createTestType } from './createTestType'; - -export { TestType } from './createTestType'; -export const test = createTestType(); - `, - ), -]; - -const createTestWildCardExport = (exports: Record): 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, - }; -}