Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/jssg-utils-refactor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@backstage/mui4-to-bui-migration-recipe': patch
'@backstage/v1-45-0-migration-recipe': patch
'@backstage/v1-47-0-migration-recipe': patch
'@backstage/v1-48-0-migration-recipe': patch
'@backstage/v1-49-0-migration-recipe': patch
'@backstage/v1-50-0-migration-recipe': patch
'@backstage/v1-51-0-migration-recipe': patch
'@backstage/v1-52-0-migration-recipe': patch
'@backstage/v1-54-0-migration-recipe': patch
'@backstage/v1-55-0-migration-recipe': patch
---

Refactor: use @jssg/utils for common codemod transformations

Replace hand-rolled import manipulation (add/remove/rename/resolve, binding
resolution, reference renaming, indent detection) across the codemod scripts
with the shared helpers from `@jssg/utils/javascript/{imports,bindings,style}`,
and standardize every recipe on `@jssg/utils ^0.0.11`. Behavior-preserving:
all fixture suites, metrics, and workflow goldens pass unchanged apart from
formatting-only fixture updates (removed import statements no longer leave a
blank line; merged import specifiers keep source order instead of being
re-sorted).

Also fixes the `migrate-extension-config-schema` typed-extension fixture
type-check by declaring the packages it imports (`@backstage/frontend-plugin-api`,
`zod`) as dev dependencies of the v1.54 recipe.
22 changes: 22 additions & 0 deletions .yarn/patches/@jssg-utils-npm-0.0.11-19369c37c8.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
diff --git a/src/javascript/exports/imports.ts b/src/javascript/exports/imports.ts
index 8f7a78220d2ecc6ebcafb817a8a94877ccb97881..3691a79f19f4d90a3cee4573d58e0ebf3ff42136 100644
--- a/src/javascript/exports/imports.ts
+++ b/src/javascript/exports/imports.ts
@@ -2247,7 +2247,7 @@ function requireObjectPatterns<T extends Language>(
},
},
],
- } satisfies Rule<Language>;
+ } as unknown as Rule<Language>;
const patterns = new Map<number, SgNode<T>>();
const specifiers = tsProgram.findAll({
rule: {
@@ -2258,7 +2258,7 @@ function requireObjectPatterns<T extends Language>(
inside: {
stopBy: "end",
kind: "variable_declarator",
- has: value,
+ has: value as unknown as never,
},
},
},
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ Conventions to follow:

Use `codemods/v1.54.0/` as a reference when adding a script and workflow step.

### Prefer shared codemod utils

Before hand-rolling a common transformation — import add/remove/rename/resolve, import binding resolution, reference renaming, indent or quote-style detection — check `@jssg/utils` first: every recipe can import it (`@jssg/utils/javascript/imports`, `.../bindings`, `.../style`; read the source under `node_modules/@jssg/utils/src/javascript/exports/` for the exact API). The utils handle edge cases (aliases, CJS `require`, dynamic `import()`, multi-line clause layout) that bespoke scans miss, and they are maintained upstream.

When a util you need seems to be missing, prefer bumping `@jssg/utils` to a newer release over recreating the logic locally — it ships new helpers frequently (0.0.11 added `listImports`, the `bindings` module, and the `style` module). Keep bespoke logic only where no util models the transformation (e.g. collision-aware rename orchestration), and say so in the PR description.

`@jssg/utils` 0.0.11 ships raw `.ts` source that does not type-check under this repo's TypeScript 7 toolchain (two assertion sites in `imports.ts`). Until upstream ships compiled declarations or a fix, the repo carries a temporary patch at `.yarn/patches/` (registered in the root `package.json` `resolutions.patchedDependencies`); it is type-only and changes no runtime behavior. Drop the patch when a newer `@jssg/utils` release type-checks cleanly.

## Adding a migration recipe

Each Backstage release publishes a single `migration-recipe` package directly under `codemods/<version>/` — the recipe is the only published package for its version, and every workflow step runs a local script instead of a nested registry codemod:
Expand Down
3 changes: 3 additions & 0 deletions codemods/misc/mui4-to-bui-migration-recipe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
"scripts": {
"test": "run-recipe-tests"
},
"dependencies": {
"@jssg/utils": "patch:@jssg/utils@npm%3A0.0.11#~/.yarn/patches/@jssg-utils-npm-0.0.11-19369c37c8.patch"
},
"devDependencies": {
"@backstage/scripts": "workspace:*",
"@codemod.com/jssg-types": "1.6.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { addImport, getAllImports, getImport, listImports, removeImport } from '@jssg/utils/javascript/imports'
import type { Codemod, Edit, SgNode } from 'codemod:ast-grep'
import type TSX from 'codemod:ast-grep/langs/tsx'
import { useMetricAtom } from 'codemod:metrics'
Expand Down Expand Up @@ -34,185 +35,32 @@ function withTodoComment(comment: string, elementText: string): string {
</>`
}

function rebuildImportWithout(importStmt: SgNode<TSX>, specifiersToRemove: Set<string>): string {
const specifiers = importStmt.findAll({ rule: { kind: 'import_specifier' } })
const remaining: string[] = []
for (const spec of specifiers) {
const identifiers = spec.findAll({
rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] },
})
const importedName = identifiers[0]?.text()
if (importedName && !specifiersToRemove.has(importedName)) {
remaining.push(spec.text())
}
}

if (remaining.length === 0) {
return ''
}

const sourceNode = importStmt.find({ rule: { kind: 'string' } })
const sourceText = sourceNode?.text() ?? `'${MUI_BARREL_SOURCE}'`

if (remaining.length <= 2) {
return `import { ${remaining.join(', ')} } from ${sourceText};`
}
return `import {\n ${remaining.join(',\n ')},\n} from ${sourceText};`
}

function findImportStatementsFrom(rootNode: SgNode<TSX>, source: string): SgNode<TSX>[] {
return rootNode.findAll({
rule: {
kind: 'import_statement',
has: {
kind: 'string',
has: {
kind: 'string_fragment',
regex: `^${escapeRegex(source)}$`,
},
},
},
})
}

function getDefaultImportName(imp: SgNode<TSX>): string | null {
const clause = imp.find({ rule: { kind: 'import_clause' } })
if (!clause) {
return null
}
for (const child of clause.children()) {
if (child.is('identifier')) {
return child.text()
}
}
return null
}

function getNamedImportLocalName(imp: SgNode<TSX>, targetName: string): string | null {
for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) {
const identifiers = spec.findAll({
rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] },
})
const [importedNameNode] = identifiers
if (importedNameNode?.text() === targetName) {
const localNameNode = identifiers[1] ?? importedNameNode
return localNameNode.text()
}
}
return null
}

interface AccordionImports {
localNames: Map<string, string>
importNodesToRemove: SgNode<TSX>[]
barrelImportsToPrune: { imp: SgNode<TSX>; namesToRemove: Set<string> }[]
defaultSources: string[]
barrelNames: string[]
}

function collectAccordionImports(rootNode: SgNode<TSX>): AccordionImports {
function collectAccordionImports(rootNode: SgNode<TSX, 'program'>): AccordionImports {
const localNames = new Map<string, string>()
const importNodesToRemove: SgNode<TSX>[] = []
const barrelImportsToPrune: { imp: SgNode<TSX>; namesToRemove: Set<string> }[] = []
const defaultSources: string[] = []
const barrelNames: string[] = []

for (const componentName of MUI_ACCORDION_COMPONENTS) {
for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) {
const name = getDefaultImportName(imp)
if (name) {
localNames.set(name, componentName)
}
importNodesToRemove.push(imp)
}
}

for (const imp of findImportStatementsFrom(rootNode, MUI_BARREL_SOURCE)) {
const foundNames = new Set<string>()
for (const componentName of MUI_ACCORDION_COMPONENTS) {
const localName = getNamedImportLocalName(imp, componentName)
if (localName) {
localNames.set(localName, componentName)
foundNames.add(componentName)
}
const defaultImport = getImport(rootNode, { type: 'default', from: `@material-ui/core/${componentName}` })
if (defaultImport) {
localNames.set(defaultImport.alias, componentName)
defaultSources.push(`@material-ui/core/${componentName}`)
}
if (foundNames.size > 0) {
const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } })
if (foundNames.size >= allSpecifiers.length) {
importNodesToRemove.push(imp)
} else {
barrelImportsToPrune.push({ imp, namesToRemove: foundNames })
}
}
}

return { localNames, importNodesToRemove, barrelImportsToPrune }
}

function addBuiImport(
rootNode: SgNode<TSX>,
importNodesToRemove: SgNode<TSX>[],
names: string[],
edits: Edit[],
barrelImportsToPrune: { imp: SgNode<TSX>; namesToRemove: Set<string> }[],
handledBarrelIds: Set<number>,
): boolean {
const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE)
const existingImport = existingImports[0] ?? null

if (existingImport) {
const namesToAdd = names.filter((name) => getNamedImportLocalName(existingImport, name) === null)
if (namesToAdd.length > 0) {
const namedImports = existingImport.find({ rule: { kind: 'named_imports' } })
if (namedImports) {
const text = namedImports.text()
const inner = text.slice(1, -1).trim()
const existing = inner
.split(',')
.map((n) => n.trim())
.filter(Boolean)
for (const name of namesToAdd) {
existing.push(name)
}
existing.sort()
edits.push(namedImports.replace(`{ ${existing.join(', ')} }`))
migrationMetric.increment({ action: 'import-merged' })
for (const named of getAllImports(rootNode, { type: 'named', name: componentName, from: MUI_BARREL_SOURCE })) {
localNames.set(named.alias, componentName)
if (!barrelNames.includes(componentName)) {
barrelNames.push(componentName)
}
}
return false
}

const sortedNames = [...names].sort()
const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`
const skipIds = new Set([
...importNodesToRemove.map((imp) => imp.id()),
...barrelImportsToPrune.map(({ imp }) => imp.id()),
])
const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } })
const anchorImport = [...allImports].reverse().find((imp) => !skipIds.has(imp.id())) ?? null

if (anchorImport) {
edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`))
migrationMetric.increment({ action: 'import-added' })
return false
}

const [barrelToFold] = barrelImportsToPrune
if (barrelToFold) {
const pruned = rebuildImportWithout(barrelToFold.imp, barrelToFold.namesToRemove)
edits.push(barrelToFold.imp.replace(pruned.length > 0 ? `${pruned}\n${buiImport}` : buiImport))
handledBarrelIds.add(barrelToFold.imp.id())
migrationMetric.increment({ action: 'import-added' })
migrationMetric.increment({ action: 'import-pruned' })
return false
}

if (importNodesToRemove.length >= 1) {
const [importNode] = importNodesToRemove
if (importNode) {
edits.push(importNode.replace(buiImport))
migrationMetric.increment({ action: 'import-added' })
return true
}
}
migrationMetric.increment({ action: 'import-added' })
return false
return { localNames, defaultSources, barrelNames }
}

function getElementName(opening: SgNode<TSX>): string | null {
Expand Down Expand Up @@ -399,7 +247,7 @@ function transformAccordionChildren(accordionElement: SgNode<TSX>, localNames: M
}

function transformAccordionElements(
rootNode: SgNode<TSX>,
rootNode: SgNode<TSX, 'program'>,
localNames: Map<string, string>,
edits: Edit[],
): { preserveImport: boolean; migrated: boolean } {
Expand Down Expand Up @@ -489,7 +337,7 @@ const transform: Codemod<TSX> = (root) => {
const rootNode = root.root()
const edits: Edit[] = []

const { localNames, importNodesToRemove, barrelImportsToPrune } = collectAccordionImports(rootNode)
const { localNames, defaultSources, barrelNames } = collectAccordionImports(rootNode)

if (localNames.size === 0) {
return Promise.resolve(null)
Expand All @@ -510,34 +358,58 @@ const transform: Codemod<TSX> = (root) => {
}
}

let replacedImport = false
const handledBarrelIds = new Set<number>()
if (buiNames.size > 0) {
replacedImport = addBuiImport(
rootNode,
importNodesToRemove,
[...buiNames],
edits,
barrelImportsToPrune,
handledBarrelIds,
)
const addEdit = addImport(rootNode, {
type: 'named',
specifiers: [...buiNames].sort().map((name) => ({ name })),
from: BUI_SOURCE,
})
if (addEdit) {
edits.push(addEdit)
const hasBuiNamedImport =
rootNode.find({
rule: {
kind: 'import_statement',
all: [
{
has: {
kind: 'string',
has: { kind: 'string_fragment', regex: `^${escapeRegex(BUI_SOURCE)}$` },
},
},
{ has: { kind: 'import_clause', has: { kind: 'named_imports' } } },
],
},
}) !== null
migrationMetric.increment({ action: hasBuiNamedImport ? 'import-merged' : 'import-added' })
}
}

if (!preserveImport) {
for (const { imp, namesToRemove } of barrelImportsToPrune) {
if (handledBarrelIds.has(imp.id())) {
continue
if (barrelNames.length > 0) {
const barrelRemovedNames = new Set(barrelNames)
for (const item of listImports(rootNode, { from: MUI_BARREL_SOURCE })) {
const matching = item.specifiers.filter((spec) => barrelRemovedNames.has(spec.importedName))
if (matching.length === 0) {
continue
}
if (matching.length >= item.specifiers.length) {
migrationMetric.increment({ action: 'import-removed' })
} else {
migrationMetric.increment({ action: 'import-pruned' })
}
}
const edit = removeImport(rootNode, { type: 'named', specifiers: barrelNames, from: MUI_BARREL_SOURCE })
if (edit) {
edits.push(...edit)
}
edits.push(imp.replace(rebuildImportWithout(imp, namesToRemove)))
migrationMetric.increment({ action: 'import-pruned' })
}
for (const imp of importNodesToRemove) {
if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) {
for (const source of defaultSources) {
const edit = removeImport(rootNode, { type: 'default', from: source })
if (edit) {
edits.push(...edit)
migrationMetric.increment({ action: 'import-removed' })
continue
}
edits.push(imp.replace(''))
migrationMetric.increment({ action: 'import-removed' })
}
}

Expand Down
Loading
Loading