diff --git a/.changeset/whats-in-a-name.md b/.changeset/whats-in-a-name.md new file mode 100644 index 0000000000..4a8c6c26dc --- /dev/null +++ b/.changeset/whats-in-a-name.md @@ -0,0 +1,11 @@ +--- +'@backstage/codemods': patch +--- + +Added an `extension-names` codemod, which adds a `name` key to all extensions +provided by plugins. Extension names are used to provide richer context to +events captured by the new Analytics API, and may also appear in debug output +and other situations. + +To apply this codemod, run `npx @backstage/codemods apply extension-names` in +the root of your Backstage monorepo. diff --git a/packages/codemods/src/codemods.ts b/packages/codemods/src/codemods.ts index 6cdd9d8977..3d366e3707 100644 --- a/packages/codemods/src/codemods.ts +++ b/packages/codemods/src/codemods.ts @@ -25,4 +25,9 @@ export const codemods: Codemod[] = [ description: 'Updates @backstage/core imports to use @backstage/core-* imports instead.', }, + { + name: 'extension-names', + description: + 'Adds a "name" property to all core extensions provided by plugins.', + }, ]; diff --git a/packages/codemods/transforms/extension-names.js b/packages/codemods/transforms/extension-names.js new file mode 100644 index 0000000000..75cbabdc81 --- /dev/null +++ b/packages/codemods/transforms/extension-names.js @@ -0,0 +1,83 @@ +/* + * Copyright 2021 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. + */ + +module.exports = (file, /** @type {import('jscodeshift').API} */ api) => { + const j = api.jscodeshift; + const root = j(file.source); + + // Find all variables set to *.provide() with an invocation of an extension + // creator as its first argument. + // For example: somePlugin.provide(createReactExtension({})) + const extensionCreators = [ + 'createReactExtension', + 'createComponentExtension', + 'createRoutableExtension', + ]; + const extensions = root + .findVariableDeclarators() + .filter( + n => + n.node.init?.type === 'CallExpression' && + n.node.init?.callee.type === 'MemberExpression' && + n.node.init?.callee?.property.name === 'provide' && + n.node.init?.arguments.length === 1 && + extensionCreators.includes(n.node.init?.arguments[0].callee?.name), + ); + + // Abort if no such variable declarations exist in this file. + if (extensions.size === 0) { + return undefined; + } + + // For each variable, parse out its name and apply the name as the value of + // the "name" key on the argument passed to the extension creator's argument. + // For example: createReactExtension({ name: 'Some Extension' }) + let numberAffected = 0; + extensions.nodes().flatMap(node => { + const creatorArgObject = node.init?.arguments[0].arguments[0]; + const hasNameKeyAlready = + creatorArgObject.properties.filter(p => p.key.name === 'name').length === + 1; + const nameProp = j.objectProperty( + j.identifier('name'), + j.literal(node.id.name), + ); + + // Apply this change only once. + if (!hasNameKeyAlready) { + numberAffected++; + creatorArgObject.properties.unshift(nameProp); + } + }); + + // Abort if no changes were made. + if (numberAffected === 0) { + return undefined; + } + + // Check for code styles to be applied to this file. + const useSingleQuote = + root.find(j.ImportDeclaration).nodes()[0]?.source.extra.raw[0] === "'"; + const useTrailingComma = root + .find(j.ObjectExpression) + .nodes()[0] + ?.extra?.hasOwnProperty('trailingComma'); + + return root.toSource({ + quote: useSingleQuote ? 'single' : 'double', + trailingComma: useTrailingComma, + }); +};