From 500a0cd60f78158862e5c18506b0090da1c103aa Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 21:21:52 -0500 Subject: [PATCH 01/12] feat: MUI 4 to BUI migration - foundation codemods Add 4 foundation codemods: - migrate-mui-bootstrap-to-bui (#108) - migrate-mui-icons-to-remix-icons (#109) - migrate-mui-styles-to-bui-css-modules (#110) - migrate-mui-layout-to-bui-layout (#111) Closes #108, #109, #110, #111 --- .changeset/mui-to-bui-foundation.md | 8 + .../migrate-mui-bootstrap-to-bui/CHANGELOG.md | 1 + .../migrate-mui-bootstrap-to-bui/codemod.yaml | 20 + .../migrate-mui-bootstrap-to-bui/package.json | 13 + .../scripts/codemod.ts | 67 ++ .../scripts/package-json-codemod.ts | 107 +++ .../tests/already-bootstrapped/expected.tsx | 5 + .../tests/already-bootstrapped/input.tsx | 5 + .../tests/already-bootstrapped/metrics.json | 10 + .../tests/app-entry-with-mui/expected.tsx | 5 + .../tests/app-entry-with-mui/input.tsx | 4 + .../tests/app-entry-with-mui/metrics.json | 10 + .../tests/icons-import/expected.tsx | 4 + .../tests/icons-import/input.tsx | 3 + .../tests/icons-import/metrics.json | 10 + .../tests/merge-existing-bui/expected.tsx | 5 + .../tests/merge-existing-bui/input.tsx | 5 + .../tests/merge-existing-bui/metrics.json | 10 + .../tests/multiple-mui-imports/expected.tsx | 12 + .../tests/multiple-mui-imports/input.tsx | 11 + .../tests/multiple-mui-imports/metrics.json | 10 + .../tests/noop-no-mui/expected.tsx | 4 + .../tests/noop-no-mui/input.tsx | 4 + .../tests/package-with-mui-core/expected.json | 8 + .../tests/package-with-mui-core/input.json | 7 + .../tests/package-with-mui-core/metrics.json | 10 + .../package-with-mui-icons/expected.json | 9 + .../tests/package-with-mui-icons/input.json | 7 + .../tests/package-with-mui-icons/metrics.json | 16 + .../tsconfig.json | 16 + .../workflow.yaml | 27 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 425 +++++++++++ .../tests/aliased-import/expected.tsx | 5 + .../tests/aliased-import/input.tsx | 5 + .../tests/aliased-import/metrics.json | 24 + .../tests/extension-icon-slot/expected.tsx | 7 + .../tests/extension-icon-slot/input.tsx | 7 + .../tests/extension-icon-slot/metrics.json | 24 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 24 + .../tests/multiple-icons/expected.tsx | 9 + .../tests/multiple-icons/input.tsx | 9 + .../tests/multiple-icons/metrics.json | 32 + .../tests/namespace-import-todo/expected.tsx | 6 + .../tests/namespace-import-todo/input.tsx | 5 + .../tests/namespace-import-todo/metrics.json | 11 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tests/simple-known-icon/expected.tsx | 5 + .../tests/simple-known-icon/input.tsx | 5 + .../tests/simple-known-icon/metrics.json | 24 + .../tests/unknown-icon-todo/expected.tsx | 6 + .../tests/unknown-icon-todo/input.tsx | 5 + .../tests/unknown-icon-todo/metrics.json | 11 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 697 ++++++++++++++++++ .../tests/box-component-todo/expected.tsx | 8 + .../tests/box-component-todo/input.tsx | 7 + .../tests/box-component-todo/metrics.json | 11 + .../tests/box-flex-container/expected.tsx | 6 + .../tests/box-flex-container/input.tsx | 7 + .../tests/box-flex-container/metrics.json | 16 + .../tests/grid-simple/expected.tsx | 8 + .../tests/grid-simple/input.tsx | 9 + .../tests/grid-simple/metrics.json | 22 + .../tests/grid-todo/expected.tsx | 13 + .../tests/grid-todo/input.tsx | 12 + .../tests/grid-todo/metrics.json | 18 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 11 + .../tests/merge-existing-bui/metrics.json | 16 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tests/paper-elevation-todo/expected.tsx | 6 + .../tests/paper-elevation-todo/input.tsx | 5 + .../tests/paper-elevation-todo/metrics.json | 11 + .../tests/paper-simple/expected.tsx | 6 + .../tests/paper-simple/input.tsx | 7 + .../tests/paper-simple/metrics.json | 16 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 543 ++++++++++++++ .../tests/direct-object-arg/expected.tsx | 9 + .../tests/direct-object-arg/input.tsx | 13 + .../tests/direct-object-arg/metrics.json | 40 + .../tests/dynamic-rule-todo/expected.tsx | 11 + .../tests/dynamic-rule-todo/input.tsx | 10 + .../tests/dynamic-rule-todo/metrics.json | 17 + .../tests/merge-existing-bui/expected.tsx | 11 + .../tests/merge-existing-bui/input.module.css | 3 + .../tests/merge-existing-bui/input.tsx | 14 + .../tests/merge-existing-bui/metrics.json | 40 + .../tests/multiple-classes/expected.tsx | 14 + .../tests/multiple-classes/input.tsx | 22 + .../tests/multiple-classes/metrics.json | 40 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tests/static-theme-tokens/expected.tsx | 9 + .../tests/static-theme-tokens/input.tsx | 14 + .../tests/static-theme-tokens/metrics.json | 40 + .../tests/with-styles-todo/expected.tsx | 6 + .../tests/with-styles-todo/input.tsx | 5 + .../tests/with-styles-todo/metrics.json | 11 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + yarn.lock | 36 + 117 files changed, 3166 insertions(+) create mode 100644 .changeset/mui-to-bui-foundation.md create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/codemod.yaml create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/package.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/expected.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/input.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/metrics.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/expected.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/input.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/metrics.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/input.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/metrics.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/expected.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/input.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/metrics.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/noop-no-mui/expected.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/noop-no-mui/input.tsx create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/expected.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/input.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/expected.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/input.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tsconfig.json create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/codemod.yaml create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/package.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/input.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/metrics.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/input.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/metrics.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/expected.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/input.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/metrics.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/expected.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/input.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/metrics.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/tsconfig.json create mode 100644 codemods/misc/migrate-mui-icons-to-remix-icons/workflow.yaml create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/codemod.yaml create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/package.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/metrics.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/metrics.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/input.tsx create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/tsconfig.json create mode 100644 codemods/misc/migrate-mui-layout-to-bui-layout/workflow.yaml create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/codemod.yaml create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/package.json create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/expected.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/input.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/metrics.json create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/input.module.css create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/expected.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/input.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/metrics.json create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/expected.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/input.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/metrics.json create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/tsconfig.json create mode 100644 codemods/misc/migrate-mui-styles-to-bui-css-modules/workflow.yaml diff --git a/.changeset/mui-to-bui-foundation.md b/.changeset/mui-to-bui-foundation.md new file mode 100644 index 0000000..8934e64 --- /dev/null +++ b/.changeset/mui-to-bui-foundation.md @@ -0,0 +1,8 @@ +--- +'@backstage/migrate-mui-bootstrap-to-bui': minor +'@backstage/migrate-mui-icons-to-remix-icons': minor +'@backstage/migrate-mui-styles-to-bui-css-modules': minor +'@backstage/migrate-mui-layout-to-bui-layout': minor +--- + +Add foundation codemods for the MUI 4 to BUI migration: bootstrap app dependencies and root CSS, replace MUI icons with Remix icons, migrate makeStyles to CSS modules, and convert layout primitives to BUI equivalents. diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/CHANGELOG.md b/codemods/misc/migrate-mui-bootstrap-to-bui/CHANGELOG.md new file mode 100644 index 0000000..dc8dae2 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-bootstrap-to-bui diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/codemod.yaml b/codemods/misc/migrate-mui-bootstrap-to-bui/codemod.yaml new file mode 100644 index 0000000..e807ae7 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-bootstrap-to-bui' +version: '0.1.0' +description: 'MUI 4 to BUI: Bootstrap app dependencies and root CSS' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts', 'json'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'bootstrap', 'bui'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/package.json b/codemods/misc/migrate-mui-bootstrap-to-bui/package.json new file mode 100644 index 0000000..5312fec --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-bootstrap-to-bui", + "version": "0.1.0", + "description": "MUI 4 to BUI: Bootstrap app dependencies and root CSS", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod jssg test -l json ./scripts/package-json-codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts new file mode 100644 index 0000000..fc0e326 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts @@ -0,0 +1,67 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-bootstrap-to-bui') + +const BUI_CSS_IMPORT = '@backstage/ui/css/styles.css' + +function findImportStatementsMatching(rootNode: SgNode, pattern: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: pattern, + }, + }, + }, + }) +} + +function hasMuiImports(rootNode: SgNode): boolean { + const muiImports = findImportStatementsMatching(rootNode, '^@material-ui/') + return muiImports.length > 0 +} + +function hasBuiCssImport(rootNode: SgNode): boolean { + const cssImports = findImportStatementsMatching(rootNode, '^@backstage/ui/css/styles\\.css$') + return cssImports.length > 0 +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + // Only process files that contain @material-ui imports + if (!hasMuiImports(rootNode)) { + return null + } + + // Skip if the BUI CSS import is already present + if (hasBuiCssImport(rootNode)) { + migrationMetric.increment({ action: 'already-bootstrapped' }) + return null + } + + // Find the first import statement to insert before it + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + if (allImports.length === 0) { + return null + } + + const firstImport = allImports[0] + if (!firstImport) { + return null + } + + // Insert the BUI CSS import before the first import + edits.push(firstImport.replace(`import '${BUI_CSS_IMPORT}';\n${firstImport.text()}`)) + migrationMetric.increment({ action: 'css-import-added' }) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts new file mode 100644 index 0000000..6890747 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts @@ -0,0 +1,107 @@ +import type { Codemod } from 'codemod:ast-grep' +import type JSON from 'codemod:ast-grep/langs/json' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-bootstrap-to-bui') + +const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/lab'] as const + +const BUI_PACKAGE = '@backstage/ui' +const REMIX_PACKAGE = '@remixicon/react' + +/** Match semver caret ranges used by other Backstage codemods (e.g. add-jest-peer-dependency). */ +const BUI_VERSION = '^0.16.0' +const REMIX_VERSION = '^4.9.0' + +interface PackageJson { + dependencies?: Record + devDependencies?: Record + [key: string]: unknown +} + +function sortObjectKeys(obj: Record): Record { + const sorted: Record = {} + for (const key of Object.keys(obj).sort()) { + const value = obj[key] + if (value !== undefined) { + sorted[key] = value + } + } + return sorted +} + +function normalizeSource(source: string): string { + return source.replaceAll('\r\n', '\n').replaceAll('\r', '\n') +} + +function hasMuiDependency(pkg: PackageJson): boolean { + return MUI_PACKAGES.some( + (name) => pkg.dependencies?.[name] !== undefined || pkg.devDependencies?.[name] !== undefined, + ) +} + +function hasMuiIcons(pkg: PackageJson): boolean { + return ( + pkg.dependencies?.['@material-ui/icons'] !== undefined || pkg.devDependencies?.['@material-ui/icons'] !== undefined + ) +} + +function dependencySectionForMui(pkg: PackageJson): 'dependencies' | 'devDependencies' { + for (const name of MUI_PACKAGES) { + if (pkg.dependencies?.[name] !== undefined) { + return 'dependencies' + } + } + return 'devDependencies' +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const source = normalizeSource(rootNode.text()) + + let pkg: PackageJson + try { + pkg = globalThis.JSON.parse(source) as PackageJson + } catch { + return null + } + + if (!hasMuiDependency(pkg)) { + return null + } + + const section = dependencySectionForMui(pkg) + const existingDeps = pkg[section] ?? {} + let changed = false + + if (existingDeps[BUI_PACKAGE] === undefined) { + existingDeps[BUI_PACKAGE] = BUI_VERSION + changed = true + migrationMetric.increment({ action: 'bui-dependency-added' }) + } + + if (hasMuiIcons(pkg) && existingDeps[REMIX_PACKAGE] === undefined) { + existingDeps[REMIX_PACKAGE] = REMIX_VERSION + changed = true + migrationMetric.increment({ action: 'remix-dependency-added' }) + } + + if (!changed) { + migrationMetric.increment({ action: 'already-bootstrapped-deps' }) + return null + } + + pkg[section] = sortObjectKeys(existingDeps) + + const indentMatch = source.match(/\n(\s+)"/) + const indent = indentMatch?.[1] ?? ' ' + const result = `${globalThis.JSON.stringify(pkg, null, indent)}\n` + + if (result === source) { + return null + } + + return result +} + +export default transform diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/expected.tsx new file mode 100644 index 0000000..c15aa62 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/expected.tsx @@ -0,0 +1,5 @@ +import '@backstage/ui/css/styles.css'; +import React from 'react'; +import { Typography } from '@material-ui/core'; + +const Page = () => Hello; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/input.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/input.tsx new file mode 100644 index 0000000..c15aa62 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/input.tsx @@ -0,0 +1,5 @@ +import '@backstage/ui/css/styles.css'; +import React from 'react'; +import { Typography } from '@material-ui/core'; + +const Page = () => Hello; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/metrics.json new file mode 100644 index 0000000..a702834 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/metrics.json @@ -0,0 +1,10 @@ +{ + "migrate-mui-bootstrap-to-bui": [ + { + "cardinality": { + "action": "already-bootstrapped" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/expected.tsx new file mode 100644 index 0000000..4f92b46 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/expected.tsx @@ -0,0 +1,5 @@ +import '@backstage/ui/css/styles.css'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import Button from '@material-ui/core/Button'; +import { App } from './App'; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/input.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/input.tsx new file mode 100644 index 0000000..52bfa5b --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/input.tsx @@ -0,0 +1,4 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import Button from '@material-ui/core/Button'; +import { App } from './App'; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/metrics.json new file mode 100644 index 0000000..8ee4de4 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/app-entry-with-mui/metrics.json @@ -0,0 +1,10 @@ +{ + "migrate-mui-bootstrap-to-bui": [ + { + "cardinality": { + "action": "css-import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/expected.tsx new file mode 100644 index 0000000..566d7b6 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/expected.tsx @@ -0,0 +1,4 @@ +import '@backstage/ui/css/styles.css'; +import DeleteIcon from '@material-ui/icons/Delete'; + +const MyComponent = () => ; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/input.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/input.tsx new file mode 100644 index 0000000..3278307 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/input.tsx @@ -0,0 +1,3 @@ +import DeleteIcon from '@material-ui/icons/Delete'; + +const MyComponent = () => ; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/metrics.json new file mode 100644 index 0000000..8ee4de4 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/metrics.json @@ -0,0 +1,10 @@ +{ + "migrate-mui-bootstrap-to-bui": [ + { + "cardinality": { + "action": "css-import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..c15aa62 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,5 @@ +import '@backstage/ui/css/styles.css'; +import React from 'react'; +import { Typography } from '@material-ui/core'; + +const Page = () => Hello; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..c15aa62 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/input.tsx @@ -0,0 +1,5 @@ +import '@backstage/ui/css/styles.css'; +import React from 'react'; +import { Typography } from '@material-ui/core'; + +const Page = () => Hello; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..a702834 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/metrics.json @@ -0,0 +1,10 @@ +{ + "migrate-mui-bootstrap-to-bui": [ + { + "cardinality": { + "action": "already-bootstrapped" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/expected.tsx new file mode 100644 index 0000000..20444d6 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/expected.tsx @@ -0,0 +1,12 @@ +import '@backstage/ui/css/styles.css'; +import React from 'react'; +import Alert from '@material-ui/lab/Alert'; +import { Button, Typography } from '@material-ui/core'; + +const Page = () => ( + <> + Info + Text + + +); diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/input.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/input.tsx new file mode 100644 index 0000000..c998d82 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/input.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import Alert from '@material-ui/lab/Alert'; +import { Button, Typography } from '@material-ui/core'; + +const Page = () => ( + <> + Info + Text + + +); diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/metrics.json new file mode 100644 index 0000000..8ee4de4 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/metrics.json @@ -0,0 +1,10 @@ +{ + "migrate-mui-bootstrap-to-bui": [ + { + "cardinality": { + "action": "css-import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/noop-no-mui/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/noop-no-mui/expected.tsx new file mode 100644 index 0000000..0f837d0 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/noop-no-mui/expected.tsx @@ -0,0 +1,4 @@ +import React from 'react'; +import { Button } from '@backstage/ui'; + +const Page = () => ; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/noop-no-mui/input.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/noop-no-mui/input.tsx new file mode 100644 index 0000000..0f837d0 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/noop-no-mui/input.tsx @@ -0,0 +1,4 @@ +import React from 'react'; +import { Button } from '@backstage/ui'; + +const Page = () => ; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/expected.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/expected.json new file mode 100644 index 0000000..a02071b --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/expected.json @@ -0,0 +1,8 @@ +{ + "name": "my-plugin", + "dependencies": { + "@backstage/ui": "^0.16.0", + "@material-ui/core": "^4.12.4", + "react": "^18.0.0" + } +} diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/input.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/input.json new file mode 100644 index 0000000..037d08d --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/input.json @@ -0,0 +1,7 @@ +{ + "name": "my-plugin", + "dependencies": { + "@material-ui/core": "^4.12.4", + "react": "^18.0.0" + } +} diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json new file mode 100644 index 0000000..04200ce --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json @@ -0,0 +1,10 @@ +{ + "migrate-mui-bootstrap-to-bui": [ + { + "cardinality": { + "action": "bui-dependency-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/expected.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/expected.json new file mode 100644 index 0000000..d23c891 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/expected.json @@ -0,0 +1,9 @@ +{ + "name": "my-plugin", + "dependencies": { + "@backstage/ui": "^0.16.0", + "@material-ui/core": "^4.12.4", + "@material-ui/icons": "^4.11.3", + "@remixicon/react": "^4.9.0" + } +} diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/input.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/input.json new file mode 100644 index 0000000..0c73ad6 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/input.json @@ -0,0 +1,7 @@ +{ + "name": "my-plugin", + "dependencies": { + "@material-ui/core": "^4.12.4", + "@material-ui/icons": "^4.11.3" + } +} diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json new file mode 100644 index 0000000..2925f7c --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json @@ -0,0 +1,16 @@ +{ + "migrate-mui-bootstrap-to-bui": [ + { + "cardinality": { + "action": "bui-dependency-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "remix-dependency-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tsconfig.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml b/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml new file mode 100644 index 0000000..c3cca24 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Add BUI and Remix dependencies to package.json' + js-ast-grep: + js_file: scripts/package-json-codemod.ts + language: 'json' + include: + - '**/package.json' + exclude: + - '**/node_modules/**' + - name: 'Bootstrap app dependencies and root CSS' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/CHANGELOG.md b/codemods/misc/migrate-mui-icons-to-remix-icons/CHANGELOG.md new file mode 100644 index 0000000..00ba8f9 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-icons-to-remix-icons diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/codemod.yaml b/codemods/misc/migrate-mui-icons-to-remix-icons/codemod.yaml new file mode 100644 index 0000000..3b2d82c --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-icons-to-remix-icons' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace MUI icons with Remix icons' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'icons', 'remix', 'icons'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/package.json b/codemods/misc/migrate-mui-icons-to-remix-icons/package.json new file mode 100644 index 0000000..faadb02 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-icons-to-remix-icons", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace MUI icons with Remix icons", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts new file mode 100644 index 0000000..0ef097a --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts @@ -0,0 +1,425 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-icons-to-remix-icons') + +const REMIX_SOURCE = '@remixicon/react' + +/** + * MUI icon name (matching the module path after @material-ui/icons/) → Remix icon named export. + * Representative subset of the most common Backstage icons. + */ +const ICON_MAP: Record = { + Search: 'RiSearchLine', + Close: 'RiCloseLine', + Delete: 'RiDeleteBinLine', + Edit: 'RiEditLine', + Add: 'RiAddLine', + Remove: 'RiSubtractLine', + Check: 'RiCheckLine', + Clear: 'RiCloseLine', + Settings: 'RiSettings3Line', + Home: 'RiHomeLine', + Menu: 'RiMenuLine', + MoreVert: 'RiMore2Line', + MoreHoriz: 'RiMoreLine', + ArrowBack: 'RiArrowLeftLine', + ArrowForward: 'RiArrowRightLine', + ArrowDropDown: 'RiArrowDownSLine', + ArrowDropUp: 'RiArrowUpSLine', + ExpandMore: 'RiArrowDownSLine', + ExpandLess: 'RiArrowUpSLine', + ChevronLeft: 'RiArrowLeftSLine', + ChevronRight: 'RiArrowRightSLine', + Visibility: 'RiEyeLine', + VisibilityOff: 'RiEyeOffLine', + Star: 'RiStarLine', + StarBorder: 'RiStarLine', + Favorite: 'RiHeartLine', + FavoriteBorder: 'RiHeartLine', + Person: 'RiUserLine', + People: 'RiGroupLine', + Group: 'RiGroupLine', + Lock: 'RiLockLine', + LockOpen: 'RiLockUnlockLine', + Notifications: 'RiNotification3Line', + Email: 'RiMailLine', + Link: 'RiLinkLine', + OpenInNew: 'RiExternalLinkLine', + FileCopy: 'RiFileCopyLine', + ContentCopy: 'RiFileCopyLine', + Refresh: 'RiRefreshLine', + Info: 'RiInformationLine', + Warning: 'RiAlertLine', + Error: 'RiErrorWarningLine', + ErrorOutline: 'RiErrorWarningLine', + Help: 'RiQuestionLine', + HelpOutline: 'RiQuestionLine', + Save: 'RiSaveLine', + Folder: 'RiFolderLine', + FolderOpen: 'RiFolderOpenLine', + InsertDriveFile: 'RiFileLine', + Description: 'RiFileTextLine', + Code: 'RiCodeLine', + Build: 'RiHammerLine', + Dashboard: 'RiDashboardLine', + Category: 'RiGridLine', + FilterList: 'RiFilterLine', + Sort: 'RiSortAsc', + PlayArrow: 'RiPlayLine', + Pause: 'RiPauseLine', + Stop: 'RiStopLine', + Language: 'RiGlobalLine', + Schedule: 'RiTimeLine', + AccessTime: 'RiTimeLine', + CalendarToday: 'RiCalendarLine', + Dns: 'RiServerLine', + Storage: 'RiDatabase2Line', + Security: 'RiShieldLine', + VpnKey: 'RiKeyLine', + AccountTree: 'RiGitBranchLine', + GitHub: 'RiGithubLine', + BugReport: 'RiBugLine', + Extension: 'RiPuzzleLine', + Layers: 'RiStackLine', + Apps: 'RiApps2Line', + ExitToApp: 'RiLogoutBoxLine', + Publish: 'RiUploadLine', + GetApp: 'RiDownloadLine', + Share: 'RiShareLine', + AttachFile: 'RiAttachmentLine', +} + +/** fontSize string value → numeric Remix size prop. */ +const FONT_SIZE_MAP: Record = { + small: 16, + inherit: 20, + default: 24, + large: 35, +} + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): 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 +} + +interface IconImportInfo { + localName: string + muiIconName: string + remixName: string | null + importNode: SgNode +} + +function collectIconImports(rootNode: SgNode): { + icons: IconImportInfo[] + namespaceImports: SgNode[] +} { + const icons: IconImportInfo[] = [] + const namespaceImports: SgNode[] = [] + + const allImports = rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: '^@material-ui/icons', + }, + }, + }, + }) + + for (const imp of allImports) { + const nsImport = imp.find({ rule: { kind: 'namespace_import' } }) + if (nsImport) { + namespaceImports.push(imp) + continue + } + + const stringFrag = imp.find({ + rule: { + kind: 'string_fragment', + regex: '^@material-ui/icons', + }, + }) + if (!stringFrag) { + continue + } + + const sourcePath = stringFrag.text() + + if (sourcePath !== '@material-ui/icons') { + const muiIconName = sourcePath.replace('@material-ui/icons/', '') + const localName = getDefaultImportName(imp) + if (localName) { + icons.push({ + localName, + muiIconName, + remixName: ICON_MAP[muiIconName] ?? null, + importNode: imp, + }) + } + continue + } + + const specifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + for (const spec of specifiers) { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const [importedNameNode] = identifiers + if (!importedNameNode) { + continue + } + const muiIconName = importedNameNode.text() + const localNameNode = identifiers[1] ?? importedNameNode + icons.push({ + localName: localNameNode.text(), + muiIconName, + remixName: ICON_MAP[muiIconName] ?? null, + importNode: imp, + }) + } + } + + return { icons, namespaceImports } +} + +function addRemixImports(rootNode: SgNode, remixImports: Map, edits: Edit[]): void { + if (remixImports.size === 0) { + return + } + + const existingImports = findImportStatementsFrom(rootNode, REMIX_SOURCE) + const existingImport = existingImports[0] ?? null + + const specifiers: string[] = [] + for (const [remixName, localName] of remixImports) { + if (remixName === localName) { + specifiers.push(remixName) + } else { + specifiers.push(`${remixName} as ${localName}`) + } + } + specifiers.sort() + + if (existingImport) { + 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 spec of specifiers) { + if (!existing.includes(spec)) { + existing.push(spec) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + } + } else { + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push( + lastImport.replace(`${lastImport.text()}\nimport { ${specifiers.join(', ')} } from '${REMIX_SOURCE}';`), + ) + } + } + } + + migrationMetric.increment({ action: 'remix-import-added' }) +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function getPropStringValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + return null +} + +function transformIconJsx(rootNode: SgNode, iconLocalNames: Set, edits: Edit[]): void { + const jsxElements = rootNode.findAll({ + rule: { + kind: 'jsx_self_closing_element', + }, + }) + + for (const el of jsxElements) { + const name = getElementName(el) + if (!name || !iconLocalNames.has(name)) { + continue + } + + const fontSizeValue = getPropStringValue(el, 'fontSize') + const sizeNum = fontSizeValue ? (FONT_SIZE_MAP[fontSizeValue] ?? null) : null + + const newProps: string[] = [] + if (sizeNum) { + newProps.push(`size={${sizeNum}}`) + } + + const droppedProps = new Set(['fontSize', 'color']) + const allAttrs = el.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + if (droppedProps.has(propIdent.text())) { + continue + } + newProps.push(attr.text()) + } + + const spreadAttrs = el.findAll({ rule: { kind: 'jsx_expression' } }) + for (const spread of spreadAttrs) { + if (spread.text().startsWith('{...')) { + newProps.push(spread.text()) + } + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + edits.push(el.replace(`<${name}${propsStr} />`)) + migrationMetric.increment({ action: 'jsx-migrated' }) + } +} + +function transformExtensionIconSlots(rootNode: SgNode, iconLocalNames: Set, edits: Edit[]): void { + const iconPairs = rootNode.findAll({ + rule: { + kind: 'pair', + has: { + kind: 'property_identifier', + regex: '^icon$', + }, + }, + }) + + for (const pair of iconPairs) { + const valueNode = pair.field('value') + if (!valueNode || !valueNode.is('identifier')) { + continue + } + + const iconName = valueNode.text() + if (!iconLocalNames.has(iconName)) { + continue + } + + edits.push(pair.replace(`icon: () => <${iconName} />`)) + migrationMetric.increment({ action: 'extension-icon-wrapped' }) + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { icons, namespaceImports } = collectIconImports(rootNode) + + if (icons.length === 0 && namespaceImports.length === 0) { + return null + } + + for (const nsImp of namespaceImports) { + edits.push( + nsImp.replace( + `${nsImp.text()}\n/* TODO(backstage-codemod): migrate this MUI icon namespace import to Remix icons manually */`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'namespace-import' }) + } + + const remixImports = new Map() + const iconLocalNames = new Set() + const processedImportIds = new Set() + + for (const icon of icons) { + if (icon.remixName) { + remixImports.set(icon.remixName, icon.localName) + iconLocalNames.add(icon.localName) + + if (!processedImportIds.has(icon.importNode.id())) { + edits.push(icon.importNode.replace('')) + processedImportIds.add(icon.importNode.id()) + } + migrationMetric.increment({ action: 'import-replaced', from: icon.muiIconName, to: icon.remixName }) + } else { + edits.push( + icon.importNode.replace( + `${icon.importNode.text()}\n/* TODO(backstage-codemod): migrate this MUI icon to a Remix icon manually */`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'unknown-icon' }) + } + } + + addRemixImports(rootNode, remixImports, edits) + transformIconJsx(rootNode, iconLocalNames, edits) + transformExtensionIconSlots(rootNode, iconLocalNames, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/expected.tsx new file mode 100644 index 0000000..a84559b --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/input.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/input.tsx new file mode 100644 index 0000000..f31560e --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/input.tsx @@ -0,0 +1,5 @@ +import MyDeleteIcon from '@material-ui/icons/Delete'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/metrics.json b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/metrics.json new file mode 100644 index 0000000..01ab5c4 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/metrics.json @@ -0,0 +1,24 @@ +{ + "migrate-mui-icons-to-remix-icons": [ + { + "cardinality": { + "action": "import-replaced", + "from": "Delete", + "to": "RiDeleteBinLine" + }, + "count": 1 + }, + { + "cardinality": { + "action": "jsx-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "remix-import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx new file mode 100644 index 0000000..592dfb1 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx @@ -0,0 +1,7 @@ + + +const navItem = { + title: 'Search', + icon: () => , + routeRef: searchRouteRef, +}; diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/input.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/input.tsx new file mode 100644 index 0000000..8ad01a0 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/input.tsx @@ -0,0 +1,7 @@ +import SearchIcon from '@material-ui/icons/Search'; + +const navItem = { + title: 'Search', + icon: SearchIcon, + routeRef: searchRouteRef, +}; diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/metrics.json b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/metrics.json new file mode 100644 index 0000000..948a0c6 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/metrics.json @@ -0,0 +1,24 @@ +{ + "migrate-mui-icons-to-remix-icons": [ + { + "cardinality": { + "action": "extension-icon-wrapped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-replaced", + "from": "Search", + "to": "RiSearchLine" + }, + "count": 1 + }, + { + "cardinality": { + "action": "remix-import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..1065a35 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ +import { RiHomeLine, RiSearchLine as SearchIcon } from '@remixicon/react'; + + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..9c18752 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import { RiHomeLine } from '@remixicon/react'; +import SearchIcon from '@material-ui/icons/Search'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..766dd5e --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/merge-existing-bui/metrics.json @@ -0,0 +1,24 @@ +{ + "migrate-mui-icons-to-remix-icons": [ + { + "cardinality": { + "action": "import-replaced", + "from": "Search", + "to": "RiSearchLine" + }, + "count": 1 + }, + { + "cardinality": { + "action": "jsx-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "remix-import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/expected.tsx new file mode 100644 index 0000000..7d3dc7e --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/expected.tsx @@ -0,0 +1,9 @@ + + + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/input.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/input.tsx new file mode 100644 index 0000000..8ae74d5 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/input.tsx @@ -0,0 +1,9 @@ +import SearchIcon from '@material-ui/icons/Search'; +import CloseIcon from '@material-ui/icons/Close'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/metrics.json b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/metrics.json new file mode 100644 index 0000000..d1b4aa8 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/metrics.json @@ -0,0 +1,32 @@ +{ + "migrate-mui-icons-to-remix-icons": [ + { + "cardinality": { + "action": "import-replaced", + "from": "Close", + "to": "RiCloseLine" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-replaced", + "from": "Search", + "to": "RiSearchLine" + }, + "count": 1 + }, + { + "cardinality": { + "action": "jsx-migrated" + }, + "count": 2 + }, + { + "cardinality": { + "action": "remix-import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/expected.tsx new file mode 100644 index 0000000..ae6e975 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/expected.tsx @@ -0,0 +1,6 @@ +import * as Icons from '@material-ui/icons'; +/* TODO(backstage-codemod): migrate this MUI icon namespace import to Remix icons manually */ + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/input.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/input.tsx new file mode 100644 index 0000000..6b44082 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/input.tsx @@ -0,0 +1,5 @@ +import * as Icons from '@material-ui/icons'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/metrics.json b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/metrics.json new file mode 100644 index 0000000..7b62972 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/namespace-import-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-icons-to-remix-icons": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "namespace-import" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..825d826 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { RiSearchLine } from '@remixicon/react'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..825d826 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { RiSearchLine } from '@remixicon/react'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/expected.tsx new file mode 100644 index 0000000..3ee2826 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/input.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/input.tsx new file mode 100644 index 0000000..1c3931d --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/input.tsx @@ -0,0 +1,5 @@ +import SearchIcon from '@material-ui/icons/Search'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/metrics.json b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/metrics.json new file mode 100644 index 0000000..766dd5e --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/metrics.json @@ -0,0 +1,24 @@ +{ + "migrate-mui-icons-to-remix-icons": [ + { + "cardinality": { + "action": "import-replaced", + "from": "Search", + "to": "RiSearchLine" + }, + "count": 1 + }, + { + "cardinality": { + "action": "jsx-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "remix-import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/expected.tsx new file mode 100644 index 0000000..8b36813 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/expected.tsx @@ -0,0 +1,6 @@ +import WeirdCustomIcon from '@material-ui/icons/WeirdCustom'; +/* TODO(backstage-codemod): migrate this MUI icon to a Remix icon manually */ + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/input.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/input.tsx new file mode 100644 index 0000000..728f0a9 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/input.tsx @@ -0,0 +1,5 @@ +import WeirdCustomIcon from '@material-ui/icons/WeirdCustom'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/metrics.json b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/metrics.json new file mode 100644 index 0000000..f02d1e2 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/unknown-icon-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-icons-to-remix-icons": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "unknown-icon" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tsconfig.json b/codemods/misc/migrate-mui-icons-to-remix-icons/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/workflow.yaml b/codemods/misc/migrate-mui-icons-to-remix-icons/workflow.yaml new file mode 100644 index 0000000..074d706 --- /dev/null +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Replace MUI icons with Remix icons' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/CHANGELOG.md b/codemods/misc/migrate-mui-layout-to-bui-layout/CHANGELOG.md new file mode 100644 index 0000000..afb1b9d --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-layout-to-bui-layout diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/codemod.yaml b/codemods/misc/migrate-mui-layout-to-bui-layout/codemod.yaml new file mode 100644 index 0000000..7a33de4 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-layout-to-bui-layout' +version: '0.1.0' +description: 'MUI 4 to BUI: Convert common MUI layout primitives to BUI layout' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'layout', 'bui', 'layout'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/package.json b/codemods/misc/migrate-mui-layout-to-bui-layout/package.json new file mode 100644 index 0000000..9a12ebd --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-layout-to-bui-layout", + "version": "0.1.0", + "description": "MUI 4 to BUI: Convert common MUI layout primitives to BUI layout", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts new file mode 100644 index 0000000..fc56493 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts @@ -0,0 +1,697 @@ +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-layout-to-bui-layout') + +const BUI_SOURCE = '@backstage/ui' + +const MUI_LAYOUT_COMPONENTS = ['Box', 'Grid', 'Paper'] + +/** Box props that map to Flex props. */ +const BOX_FLEX_PROP_MAP: Record = { + flexDirection: 'direction', + alignItems: 'align', + justifyContent: 'justify', + flexWrap: 'wrap', + gap: 'gap', +} + +/** Box props that trigger a TODO — polymorphic or complex usage. */ +const BOX_TODO_PROPS = new Set(['component', 'clone', 'css', 'sx', 'classes']) + +/** Paper props that trigger a TODO. */ +const PAPER_TODO_PROPS = new Set(['variant', 'elevation', 'square', 'component', 'classes']) + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getDefaultImportName(imp: SgNode): 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, 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 LayoutImports { + localNames: Map + importNodes: SgNode[] +} + +function collectLayoutImports(rootNode: SgNode): LayoutImports { + const localNames = new Map() + const importNodes: SgNode[] = [] + + for (const componentName of MUI_LAYOUT_COMPONENTS) { + for (const imp of findImportStatementsFrom(rootNode, `@material-ui/core/${componentName}`)) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, componentName) + } + importNodes.push(imp) + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + for (const componentName of MUI_LAYOUT_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + } + } + const hasLayoutImport = MUI_LAYOUT_COMPONENTS.some( + (componentName) => getNamedImportLocalName(imp, componentName) !== null, + ) + if (hasLayoutImport) { + importNodes.push(imp) + } + } + + return { localNames, importNodes } +} + +function isMuiComponentStillUsed(rootNode: SgNode, localName: string): boolean { + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + for (const child of opening.children()) { + if (child.is('identifier') && child.text() === localName) { + return true + } + } + } + + return false +} + +function removeUnusedLayoutImports( + rootNode: SgNode, + localNames: Map, + importNodes: SgNode[], + edits: Edit[], +): void { + const seenImportIds = new Set() + + for (const imp of importNodes) { + if (seenImportIds.has(imp.id())) { + continue + } + seenImportIds.add(imp.id()) + + const defaultName = getDefaultImportName(imp) + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + + if (defaultName && allSpecifiers.length === 0) { + if (localNames.has(defaultName) && !isMuiComponentStillUsed(rootNode, defaultName)) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + continue + } + + if (defaultName && localNames.has(defaultName) && !isMuiComponentStillUsed(rootNode, defaultName)) { + // Default + named imports: drop the default binding when unused; trim named below. + } + + const remainingSpecifiers = allSpecifiers.filter((spec) => { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + const importedNameNode = identifiers[0] + if (!importedNameNode) { + return true + } + const localNameNode = identifiers[1] ?? importedNameNode + const localName = localNameNode.text() + if (!localNames.has(localName)) { + return true + } + return isMuiComponentStillUsed(rootNode, localName) + }) + + if (remainingSpecifiers.length === 0) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } else if (remainingSpecifiers.length < allSpecifiers.length) { + const namedImports = imp.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + edits.push(namedImports.replace(`{ ${remainingSpecifiers.map((s) => s.text()).join(', ')} }`)) + migrationMetric.increment({ action: 'import-trimmed' }) + } + } + } +} + +function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + 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 names) { + if (!existing.includes(name)) { + existing.push(name) + } + } + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } else { + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const sortedNames = [...names].sort() + if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push( + lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + } + } + migrationMetric.increment({ action: 'import-added' }) + } +} + +function getElementName(opening: SgNode): string | null { + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function getPropAttr(opening: SgNode, propName: string): SgNode | null { + return opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) +} + +function hasProp(opening: SgNode, propName: string): boolean { + return getPropAttr(opening, propName) !== null +} + +function getPropStringValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + return null +} + +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + for (const child of attr.children()) { + const kind = child.kind() + if (kind === 'string' || kind === 'jsx_expression') { + return child.text() + } + } + return null +} + +function isPropDynamic(opening: SgNode, propName: string): boolean { + const attr = getPropAttr(opening, propName) + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'jsx_expression' } }) !== null +} + +function getChildContent(element: SgNode): string { + return element + .children() + .filter((child) => child.kind() !== 'jsx_opening_element' && child.kind() !== 'jsx_closing_element') + .map((child) => child.text()) + .join('') +} + +/** + * Check if a Box element is being used as a flex container. + */ +function isFlexBox(opening: SgNode): boolean { + const displayStr = getPropStringValue(opening, 'display') + if (displayStr === 'flex' || displayStr === 'inline-flex') { + return true + } + if ( + hasProp(opening, 'flexDirection') || + hasProp(opening, 'alignItems') || + hasProp(opening, 'justifyContent') || + hasProp(opening, 'flexWrap') + ) { + return true + } + return false +} + +function transformBoxElement(el: SgNode, opening: SgNode, edits: Edit[]): string | null { + // Check for TODO-triggering props + for (const prop of BOX_TODO_PROPS) { + if (hasProp(opening, prop)) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify BUI layout mapping manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: `box-${prop}` }) + return null + } + } + + // Check for dynamic display prop + if (isPropDynamic(opening, 'display')) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify BUI layout mapping manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-display' }) + return null + } + + if (!isFlexBox(opening)) { + // Non-flex Box — TODO + edits.push(el.replace(`{/* TODO(backstage-codemod): verify BUI layout mapping manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'non-flex-box' }) + return null + } + + // Build Flex props + const newProps: string[] = [] + const handledProps = new Set(['display']) + + for (const [muiProp, buiProp] of Object.entries(BOX_FLEX_PROP_MAP)) { + const strVal = getPropStringValue(opening, muiProp) + const rawVal = getPropRawValue(opening, muiProp) + if (strVal !== null) { + newProps.push(`${buiProp}="${strVal}"`) + } else if (rawVal !== null) { + newProps.push(`${buiProp}=${rawVal}`) + } + handledProps.add(muiProp) + } + + // Preserve unhandled safe props (className, style, data-*, etc.) + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (handledProps.has(propName)) { + continue + } + // Skip MUI-only spacing shorthand props + if ( + ['p', 'px', 'py', 'pt', 'pb', 'pl', 'pr', 'm', 'mx', 'my', 'mt', 'mb', 'ml', 'mr', 'padding', 'margin'].includes( + propName, + ) + ) { + migrationMetric.increment({ action: 'spacing-prop-dropped', prop: propName }) + continue + } + newProps.push(attr.text()) + } + + // Preserve spread attributes + const spreadAttrs = opening.findAll({ rule: { kind: 'jsx_expression' } }) + for (const spread of spreadAttrs) { + if (spread.text().startsWith('{...')) { + newProps.push(spread.text()) + } + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + const isSelfClosing = el.is('jsx_self_closing_element') + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const children = getChildContent(el) + edits.push(el.replace(`${children}`)) + } + + migrationMetric.increment({ action: 'box-to-flex' }) + return 'Flex' +} + +function transformPaperElement(el: SgNode, opening: SgNode, edits: Edit[]): string | null { + for (const prop of PAPER_TODO_PROPS) { + if (hasProp(opening, prop)) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify BUI layout mapping manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: `paper-${prop}` }) + return null + } + } + + const isSelfClosing = el.is('jsx_self_closing_element') + const newProps: string[] = [] + + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + newProps.push(attr.text()) + } + + const spreadAttrs = opening.findAll({ rule: { kind: 'jsx_expression' } }) + for (const spread of spreadAttrs) { + if (spread.text().startsWith('{...')) { + newProps.push(spread.text()) + } + } + + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const children = getChildContent(el) + edits.push(el.replace(`${children}`)) + } + + migrationMetric.increment({ action: 'paper-to-surface' }) + return 'Surface' +} + +const GRID_BREAKPOINTS = ['xs', 'sm', 'md', 'lg', 'xl'] as const + +const GRID_TODO_PROPS = new Set([ + 'alignItems', + 'alignContent', + 'justify', + 'justifyContent', + 'wrap', + 'direction', + 'component', + 'classes', +]) + +function hasBooleanProp(opening: SgNode, propName: string): boolean { + return getPropAttr(opening, propName) !== null +} + +function getPropStaticNumericValue(opening: SgNode, propName: string): string | null { + const strVal = getPropStringValue(opening, propName) + if (strVal !== null) { + return strVal + } + + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + + const jsxExpr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!jsxExpr) { + return null + } + + const inner = jsxExpr.text().slice(1, -1).trim() + if (/^\d+$/.test(inner)) { + return inner + } + + return null +} + +function muiSpacingToBuiGap(spacing: string): string { + return String(Number(spacing) * 2) +} + +function buildGridColSpanProp(opening: SgNode): string | null { + const entries: string[] = [] + + for (const breakpoint of GRID_BREAKPOINTS) { + const value = getPropStaticNumericValue(opening, breakpoint) + if (value === null) { + if (hasProp(opening, breakpoint)) { + return null + } + continue + } + entries.push(`${breakpoint}: '${value}'`) + } + + if (entries.length === 0) { + return null + } + + return `colSpan={{ ${entries.join(', ')} }}` +} + +function buildGridRootProps(opening: SgNode): { props: string[]; isTodo: boolean } { + const props = [`columns={{ sm: '12' }}`] + const handledProps = new Set(['container', 'item']) + + const spacingValue = getPropStaticNumericValue(opening, 'spacing') + if (spacingValue !== null) { + props.push(`gap="${muiSpacingToBuiGap(spacingValue)}"`) + handledProps.add('spacing') + } else if (hasProp(opening, 'spacing')) { + return { props: [], isTodo: true } + } + + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (handledProps.has(propName)) { + continue + } + props.push(attr.text()) + } + + return { props, isTodo: false } +} + +function buildGridItemProps(opening: SgNode): { props: string[]; isTodo: boolean } { + const props: string[] = [] + const handledProps = new Set(['container', 'item', ...GRID_BREAKPOINTS]) + + const colSpanProp = buildGridColSpanProp(opening) + if (colSpanProp === null) { + return { props: [], isTodo: true } + } + props.push(colSpanProp) + + const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) + for (const attr of allAttrs) { + const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) + if (!propIdent) { + continue + } + const propName = propIdent.text() + if (handledProps.has(propName)) { + continue + } + props.push(attr.text()) + } + + return { props, isTodo: false } +} + +function transformGridElement(el: SgNode, opening: SgNode, edits: Edit[]): string | null { + for (const prop of GRID_TODO_PROPS) { + if (hasProp(opening, prop)) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify BUI layout mapping manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: `grid-${prop}` }) + return null + } + } + + const isContainer = hasBooleanProp(opening, 'container') + const isItem = hasBooleanProp(opening, 'item') + + if (!isContainer && !isItem) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify BUI layout mapping manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'grid-unknown-role' }) + return null + } + + const isSelfClosing = el.is('jsx_self_closing_element') + + if (isContainer) { + const { props, isTodo } = buildGridRootProps(opening) + if (isTodo) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify BUI layout mapping manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'grid-dynamic-spacing' }) + return null + } + + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const children = getChildContent(el) + edits.push(el.replace(`${children}`)) + } + + migrationMetric.increment({ action: 'grid-container-to-root' }) + return 'Grid' + } + + const { props, isTodo } = buildGridItemProps(opening) + if (isTodo) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify BUI layout mapping manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'grid-item-colspan' }) + return null + } + + const propsStr = props.length > 0 ? ` ${props.join(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + const children = getChildContent(el) + edits.push(el.replace(`${children}`)) + } + + migrationMetric.increment({ action: 'grid-item-migrated' }) + return 'Grid' +} + +function transformLayoutElements(rootNode: SgNode, localNames: Map, edits: Edit[]): Set { + const usedBuiNames = new Set() + + const jsxElements = rootNode.findAll({ + rule: { + any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], + }, + }) + + for (const el of jsxElements) { + const isSelfClosing = el.is('jsx_self_closing_element') + const opening = isSelfClosing ? el : el.child(0) + if (!opening) { + continue + } + + const name = getElementName(opening) + if (!name) { + continue + } + + const muiName = localNames.get(name) + if (!muiName) { + continue + } + + if (muiName === 'Box') { + const buiName = transformBoxElement(el, opening, edits) + if (buiName) { + usedBuiNames.add(buiName) + } + continue + } + + if (muiName === 'Paper') { + const buiName = transformPaperElement(el, opening, edits) + if (buiName) { + usedBuiNames.add(buiName) + } + continue + } + + if (muiName === 'Grid') { + const buiName = transformGridElement(el, opening, edits) + if (buiName) { + usedBuiNames.add(buiName) + } + continue + } + } + + return usedBuiNames +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodes } = collectLayoutImports(rootNode) + + if (localNames.size === 0) { + return null + } + + // Transform elements before removing imports so TODO paths keep MUI imports. + const usedBuiNames = transformLayoutElements(rootNode, localNames, edits) + + removeUnusedLayoutImports(rootNode, localNames, importNodes, edits) + + if (usedBuiNames.size > 0) { + addBuiImport(rootNode, [...usedBuiNames], edits) + } + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/expected.tsx new file mode 100644 index 0000000..28e1c31 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/expected.tsx @@ -0,0 +1,8 @@ +import Box from '@material-ui/core/Box'; + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify BUI layout mapping manually */} + + Home + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/input.tsx new file mode 100644 index 0000000..847c542 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/input.tsx @@ -0,0 +1,7 @@ +import Box from '@material-ui/core/Box'; + +const MyComponent = () => ( + + Home + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/metrics.json new file mode 100644 index 0000000..f91a8d6 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-component-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-layout-to-bui-layout": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "box-component" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/expected.tsx new file mode 100644 index 0000000..5acbe48 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/expected.tsx @@ -0,0 +1,6 @@ +import Box from '@material-ui/core/Box'; +import { Flex } from '@backstage/ui'; + +const MyComponent = () => ( + {children} +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/input.tsx new file mode 100644 index 0000000..b08cb5d --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/input.tsx @@ -0,0 +1,7 @@ +import Box from '@material-ui/core/Box'; + +const MyComponent = () => ( + + {children} + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/metrics.json new file mode 100644 index 0000000..081027b --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/metrics.json @@ -0,0 +1,16 @@ +{ + "migrate-mui-layout-to-bui-layout": [ + { + "cardinality": { + "action": "box-to-flex" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx new file mode 100644 index 0000000..eff0dab --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx @@ -0,0 +1,8 @@ +import Grid from '@material-ui/core/Grid'; +import { Grid } from '@backstage/ui'; + +const MyComponent = () => ( + + Content + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/input.tsx new file mode 100644 index 0000000..fa794a5 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/input.tsx @@ -0,0 +1,9 @@ +import Grid from '@material-ui/core/Grid'; + +const MyComponent = () => ( + + + Content + + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/metrics.json new file mode 100644 index 0000000..6b637b8 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-layout-to-bui-layout": [ + { + "cardinality": { + "action": "grid-container-to-root" + }, + "count": 1 + }, + { + "cardinality": { + "action": "grid-item-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/expected.tsx new file mode 100644 index 0000000..bb5d57c --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/expected.tsx @@ -0,0 +1,13 @@ +import Grid from '@material-ui/core/Grid'; + +const spacing = 3; +const columns = 12; + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify BUI layout mapping manually */} + + + Content + + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/input.tsx new file mode 100644 index 0000000..06c8862 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/input.tsx @@ -0,0 +1,12 @@ +import Grid from '@material-ui/core/Grid'; + +const spacing = 3; +const columns = 12; + +const MyComponent = () => ( + + + Content + + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/metrics.json new file mode 100644 index 0000000..d06f47a --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-todo/metrics.json @@ -0,0 +1,18 @@ +{ + "migrate-mui-layout-to-bui-layout": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "grid-dynamic-spacing" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "grid-item-colspan" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..ecfbd5f --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ +import Box from '@material-ui/core/Box'; +import { Flex, Grid } from '@backstage/ui'; + +const MyComponent = () => ( + <> + Existing + {children} + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..650f0cb --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/input.tsx @@ -0,0 +1,11 @@ +import Box from '@material-ui/core/Box'; +import { Flex, Grid } from '@backstage/ui'; + +const MyComponent = () => ( + <> + Existing + + {children} + + +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..0cc6fb0 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/metrics.json @@ -0,0 +1,16 @@ +{ + "migrate-mui-layout-to-bui-layout": [ + { + "cardinality": { + "action": "box-to-flex" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..b7c8309 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Flex } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..b7c8309 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Flex } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/expected.tsx new file mode 100644 index 0000000..3207ca7 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/expected.tsx @@ -0,0 +1,6 @@ +import Paper from '@material-ui/core/Paper'; + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify BUI layout mapping manually */} +Content +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/input.tsx new file mode 100644 index 0000000..56e2046 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/input.tsx @@ -0,0 +1,5 @@ +import Paper from '@material-ui/core/Paper'; + +const MyComponent = () => ( + Content +); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/metrics.json new file mode 100644 index 0000000..92b14ee --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-elevation-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-layout-to-bui-layout": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "paper-elevation" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx new file mode 100644 index 0000000..b1a9e23 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx @@ -0,0 +1,6 @@ +import Paper from '@material-ui/core/Paper'; +import { Surface } from '@backstage/ui'; + +const MyComponent = () => ( +

Content

+); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/input.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/input.tsx new file mode 100644 index 0000000..ddc13c0 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/input.tsx @@ -0,0 +1,7 @@ +import Paper from '@material-ui/core/Paper'; + +const MyComponent = () => ( + +

Content

+
+); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json new file mode 100644 index 0000000..381a2ce --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json @@ -0,0 +1,16 @@ +{ + "migrate-mui-layout-to-bui-layout": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "paper-to-surface" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tsconfig.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/workflow.yaml b/codemods/misc/migrate-mui-layout-to-bui-layout/workflow.yaml new file mode 100644 index 0000000..ffa9c67 --- /dev/null +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Convert common MUI layout primitives to BUI layout' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/CHANGELOG.md b/codemods/misc/migrate-mui-styles-to-bui-css-modules/CHANGELOG.md new file mode 100644 index 0000000..50d3239 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-styles-to-bui-css-modules diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/codemod.yaml b/codemods/misc/migrate-mui-styles-to-bui-css-modules/codemod.yaml new file mode 100644 index 0000000..2800e6b --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-styles-to-bui-css-modules' +version: '0.1.0' +description: 'MUI 4 to BUI: Migrate makeStyles usage to BUI CSS modules' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'styles', 'bui', 'css', 'modules'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/package.json b/codemods/misc/migrate-mui-styles-to-bui-css-modules/package.json new file mode 100644 index 0000000..fdda882 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-styles-to-bui-css-modules", + "version": "0.1.0", + "description": "MUI 4 to BUI: Migrate makeStyles usage to BUI CSS modules", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts new file mode 100644 index 0000000..5bc306f --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts @@ -0,0 +1,543 @@ +import { jssgTransform } from 'codemod:ast-grep' +import type { Codemod, Edit, SgNode } from 'codemod:ast-grep' +import type CSS from 'codemod:ast-grep/langs/css' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('migrate-mui-styles-to-bui-css-modules') + +/** + * Map JSS camelCase property names to CSS kebab-case. + */ +function camelToKebab(str: string): string { + return str.replaceAll(/[A-Z]/g, (m) => `-${m.toLowerCase()}`) +} + +/** + * Map common MUI theme token expressions to BUI CSS variables. + */ +const THEME_TOKEN_MAP: Record = { + 'theme.spacing(1)': 'var(--bui-space-2)', + 'theme.spacing(2)': 'var(--bui-space-4)', + 'theme.spacing(3)': 'var(--bui-space-6)', + 'theme.spacing(4)': 'var(--bui-space-8)', + 'theme.spacing(0.5)': 'var(--bui-space-1)', + 'theme.spacing(0)': '0', + 'theme.palette.background.paper': 'var(--bui-bg-neutral-1)', + 'theme.palette.background.default': 'var(--bui-bg-neutral-0)', + 'theme.palette.text.primary': 'var(--bui-color-text-primary)', + 'theme.palette.text.secondary': 'var(--bui-color-text-secondary)', + 'theme.palette.text.disabled': 'var(--bui-color-text-disabled)', + 'theme.palette.divider': 'var(--bui-color-border-default)', + 'theme.palette.primary.main': 'var(--bui-color-primary)', + 'theme.palette.secondary.main': 'var(--bui-color-secondary)', + 'theme.palette.error.main': 'var(--bui-color-danger)', + 'theme.palette.warning.main': 'var(--bui-color-warning)', + 'theme.palette.info.main': 'var(--bui-color-info)', + 'theme.palette.success.main': 'var(--bui-color-success)', + 'theme.shape.borderRadius': 'var(--bui-radius-2)', +} + +function escapeRegex(str: string): string { + return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode[] { + return rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: `^${escapeRegex(source)}$`, + }, + }, + }, + }) +} + +function getNamedImportLocalName(imp: SgNode, 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 StylesImportInfo { + makeStylesLocal: string | null + withStylesLocal: string | null + importNodesToRemove: SgNode[] +} + +function collectStylesImports(rootNode: SgNode): StylesImportInfo { + let makeStylesLocal: string | null = null + let withStylesLocal: string | null = null + const importNodesToRemove: SgNode[] = [] + + const sources = ['@material-ui/core/styles', '@material-ui/core', '@material-ui/styles'] + + for (const source of sources) { + for (const imp of findImportStatementsFrom(rootNode, source)) { + const ms = getNamedImportLocalName(imp, 'makeStyles') + const cs = getNamedImportLocalName(imp, 'createStyles') + const ws = getNamedImportLocalName(imp, 'withStyles') + + if (ms) { + makeStylesLocal = ms + } + if (ws) { + withStylesLocal = ws + } + + if (ms || cs || ws) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + const targetCount = (ms ? 1 : 0) + (cs ? 1 : 0) + (ws ? 1 : 0) + if (targetCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + } + + return { makeStylesLocal, withStylesLocal, importNodesToRemove } +} + +/** + * Try to convert a JSS property value to a CSS value. + * Returns null if the value is dynamic / unmappable. + */ +function tryMapJssValue(valueNode: SgNode): string | null { + const text = valueNode.text().trim() + + // Direct theme token mapping + const mapped = THEME_TOKEN_MAP[text] + if (mapped) { + return mapped + } + + // String literal → use raw value + if (valueNode.kind() === 'string') { + const frag = valueNode.find({ rule: { kind: 'string_fragment' } }) + return frag?.text() ?? null + } + + // Number literal → append px (except 0) + if (valueNode.kind() === 'number') { + return text === '0' ? '0' : `${text}px` + } + + // If it starts with theme. but isn't in our map, it's unmappable + if (text.startsWith('theme.')) { + return null + } + + return null +} + +interface CssRule { + className: string + properties: { prop: string; value: string }[] +} + +/** + * Extract static CSS rules from a makeStyles object. + */ +function extractCssRules(styleObjNode: SgNode): { rules: CssRule[]; hasDynamic: boolean } { + const rules: CssRule[] = [] + let hasDynamic = false + + for (const child of styleObjNode.children()) { + if (child.kind() !== 'pair') { + continue + } + + const keyNode = child.find({ rule: { any: [{ kind: 'property_identifier' }, { kind: 'string' }] } }) + if (!keyNode) { + continue + } + + const className = + keyNode.kind() === 'string' + ? (keyNode.find({ rule: { kind: 'string_fragment' } })?.text() ?? keyNode.text()) + : keyNode.text() + + // Get the value — skip key and colon + const valueNode = child + .children() + .filter( + (c) => c.kind() !== 'property_identifier' && c.kind() !== ':' && c.kind() !== 'string' && c.kind() !== ',', + )[0] + + if (!valueNode) { + continue + } + + // Dynamic rule: value is a function + if ( + valueNode.kind() === 'arrow_function' || + valueNode.kind() === 'function_expression' || + valueNode.kind() === 'function' + ) { + hasDynamic = true + continue + } + + if (valueNode.kind() !== 'object') { + hasDynamic = true + continue + } + + const properties: { prop: string; value: string }[] = [] + let ruleHasDynamic = false + + for (const propPair of valueNode.children()) { + if (propPair.kind() !== 'pair') { + continue + } + + const propKeyNode = propPair.find({ + rule: { any: [{ kind: 'property_identifier' }, { kind: 'string' }] }, + }) + if (!propKeyNode) { + continue + } + + const propName = + propKeyNode.kind() === 'string' + ? (propKeyNode.find({ rule: { kind: 'string_fragment' } })?.text() ?? propKeyNode.text()) + : propKeyNode.text() + + const propValueNode = propPair + .children() + .filter( + (c) => + c.kind() !== 'property_identifier' && + c.kind() !== ':' && + c.kind() !== 'string' && + c.kind() !== ',' && + c.kind() !== propKeyNode.kind(), + )[0] + + if (!propValueNode) { + ruleHasDynamic = true + continue + } + + const cssValue = tryMapJssValue(propValueNode) + if (cssValue === null) { + ruleHasDynamic = true + continue + } + + properties.push({ prop: camelToKebab(propName), value: cssValue }) + } + + if (ruleHasDynamic) { + hasDynamic = true + continue + } + + if (properties.length > 0) { + rules.push({ className, properties }) + } + } + + return { rules, hasDynamic } +} + +/** + * Generate CSS module content from extracted rules. + */ +function generateCssModule(rules: CssRule[]): string { + const lines: string[] = ['@layer components {'] + + for (const rule of rules) { + lines.push(` .${rule.className} {`) + for (const prop of rule.properties) { + lines.push(` ${prop.prop}: ${prop.value};`) + } + lines.push(' }') + } + + lines.push('}') + return lines.join('\n') +} + +/** + * Normalize file paths from the codemod runner (handles Windows long paths). + */ +function normalizeFilePath(filename: string): string { + return filename.replace(/^\\\\\?\\/, '').replaceAll('\\', '/') +} + +/** + * Derive the CSS module import path relative to the source file. + */ +function deriveCssModuleImportPath(filename: string): string { + const normalized = normalizeFilePath(filename) + const base = + normalized + .replace(/\.[^.]+$/, '') + .split('/') + .pop() ?? 'styles' + return `./${base}.module.css` +} + +/** + * Derive the CSS module file path adjacent to the source file. + */ +function deriveCssModuleFilePath(filename: string): string { + const normalized = normalizeFilePath(filename) + return normalized.replace(/\.[^.]+$/, '.module.css') +} + +async function writeCssModuleFile(cssFilePath: string, content: string): Promise { + const writeCss: Codemod = async () => content + await jssgTransform(writeCss, cssFilePath, 'css') +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { makeStylesLocal, withStylesLocal, importNodesToRemove } = collectStylesImports(rootNode) + + if (!makeStylesLocal && !withStylesLocal) { + return null + } + + // withStyles is always a TODO — too complex for deterministic migration + if (withStylesLocal) { + const withStylesCalls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${escapeRegex(withStylesLocal)}$`, + }, + }, + }) + + for (const call of withStylesCalls) { + const stmt = call + .ancestors() + .find( + (a) => + a.kind() === 'lexical_declaration' || + a.kind() === 'expression_statement' || + a.kind() === 'export_statement', + ) + const target = stmt ?? call + edits.push( + target.replace(`// TODO(backstage-codemod): migrate withStyles to CSS Modules manually\n${target.text()}`), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'withStyles' }) + } + + if (!makeStylesLocal) { + return edits.length > 0 ? rootNode.commitEdits(edits) : null + } + } + + // Find the makeStyles call: const useStyles = makeStyles(...) + const makeStylesDeclarations = rootNode.findAll({ + rule: { + kind: 'lexical_declaration', + has: { + kind: 'variable_declarator', + has: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${escapeRegex(makeStylesLocal!)}$`, + }, + }, + }, + }, + }) + + if (makeStylesDeclarations.length === 0) { + return edits.length > 0 ? rootNode.commitEdits(edits) : null + } + + for (const decl of makeStylesDeclarations) { + const declarator = decl.find({ rule: { kind: 'variable_declarator' } }) + if (!declarator) { + continue + } + + const hookName = declarator.field('name')?.text() + if (!hookName) { + continue + } + + const callExpr = declarator.find({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${escapeRegex(makeStylesLocal!)}$`, + }, + }, + }) + + if (!callExpr) { + continue + } + + const args = callExpr.find({ rule: { kind: 'arguments' } }) + if (!args) { + continue + } + + // Find the style object (may be inside an arrow function or direct object) + let styleObj: SgNode | null = null + let isDynamicFactory = false + + const arrowFn = args.find({ rule: { kind: 'arrow_function' } }) + if (arrowFn) { + const body = arrowFn.field('body') + if (body) { + if (body.kind() === 'parenthesized_expression') { + styleObj = body.find({ rule: { kind: 'object' } }) + } else if (body.kind() === 'object') { + styleObj = body + } else { + isDynamicFactory = true + } + } + } else { + styleObj = args.find({ rule: { kind: 'object' } }) + } + + if (isDynamicFactory || !styleObj) { + edits.push( + decl.replace(`// TODO(backstage-codemod): migrate dynamic JSS rule to CSS Modules manually\n${decl.text()}`), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-factory' }) + continue + } + + const { rules, hasDynamic } = extractCssRules(styleObj) + + if (hasDynamic || rules.length === 0) { + edits.push( + decl.replace(`// TODO(backstage-codemod): migrate dynamic JSS rule to CSS Modules manually\n${decl.text()}`), + ) + migrationMetric.increment({ + action: 'todo-inserted', + reason: hasDynamic ? 'dynamic-rules' : 'empty-rules', + }) + continue + } + + // Generate CSS and write the adjacent module file + const cssContent = generateCssModule(rules) + const cssModuleImportPath = deriveCssModuleImportPath(root.filename()) + const cssModuleFilePath = deriveCssModuleFilePath(root.filename()) + await writeCssModuleFile(cssModuleFilePath, `${cssContent}\n`) + + // Remove the makeStyles declaration + edits.push(decl.replace('')) + migrationMetric.increment({ action: 'makeStyles-removed' }) + + // Add CSS module import after existing imports (avoid mutating imports slated for removal) + const importsToRemoveIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const survivingImports = rootNode + .findAll({ rule: { kind: 'import_statement' } }) + .filter((imp) => !importsToRemoveIds.has(imp.id())) + const cssImportLine = `import styles from '${cssModuleImportPath}';\n` + + if (survivingImports.length > 0) { + const lastSurvivingImport = survivingImports.at(-1) + if (lastSurvivingImport) { + const insertAt = lastSurvivingImport.range().end.index + edits.push({ + startPos: insertAt, + endPos: insertAt, + insertedText: `\n${cssImportLine}`, + }) + } + } else { + const firstNode = rootNode.children()[0] + if (firstNode) { + edits.push({ + startPos: firstNode.range().start.index, + endPos: firstNode.range().start.index, + insertedText: cssImportLine, + }) + } + } + migrationMetric.increment({ action: 'css-module-import-added' }) + migrationMetric.increment({ action: 'css-module-file-written' }) + + // Find and remove the useStyles() hook call + const hookCalls = rootNode.findAll({ + rule: { + kind: 'lexical_declaration', + has: { + kind: 'variable_declarator', + has: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${escapeRegex(hookName)}$`, + }, + }, + }, + }, + }) + + for (const hookCall of hookCalls) { + const hookDeclarator = hookCall.find({ rule: { kind: 'variable_declarator' } }) + const classesName = hookDeclarator?.field('name')?.text() + + if (classesName) { + // Replace all classes.X references with styles.X + const memberExprs = rootNode.findAll({ + rule: { + kind: 'member_expression', + has: { + field: 'object', + kind: 'identifier', + regex: `^${escapeRegex(classesName)}$`, + }, + }, + }) + + for (const memberExpr of memberExprs) { + const prop = memberExpr.field('property') + if (prop) { + edits.push(memberExpr.replace(`styles.${prop.text()}`)) + migrationMetric.increment({ action: 'classes-ref-replaced' }) + } + } + } + + edits.push(hookCall.replace('')) + migrationMetric.increment({ action: 'hook-call-removed' }) + } + } + + // Remove the MUI styles imports + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/expected.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/expected.tsx new file mode 100644 index 0000000..b986f45 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/expected.tsx @@ -0,0 +1,9 @@ +import styles from './input.module.css'; + + + + +const MyComponent = () => { + + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/input.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/input.tsx new file mode 100644 index 0000000..2e30424 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/input.tsx @@ -0,0 +1,13 @@ +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles({ + wrapper: { + padding: 0, + margin: 0, + }, +}); + +const MyComponent = () => { + const classes = useStyles(); + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/metrics.json b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/metrics.json new file mode 100644 index 0000000..530a753 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/direct-object-arg/metrics.json @@ -0,0 +1,40 @@ +{ + "migrate-mui-styles-to-bui-css-modules": [ + { + "cardinality": { + "action": "classes-ref-replaced" + }, + "count": 1 + }, + { + "cardinality": { + "action": "css-module-file-written" + }, + "count": 1 + }, + { + "cardinality": { + "action": "css-module-import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "hook-call-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "makeStyles-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/expected.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/expected.tsx new file mode 100644 index 0000000..a3a06cf --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/expected.tsx @@ -0,0 +1,11 @@ + + +// TODO(backstage-codemod): migrate dynamic JSS rule to CSS Modules manually +const useStyles = makeStyles(theme => ({ + root: props => ({ color: props.active ? theme.palette.primary.main : theme.palette.text.secondary }), +})); + +const MyComponent = (props: any) => { + const classes = useStyles(props); + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/input.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/input.tsx new file mode 100644 index 0000000..5b84f8a --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/input.tsx @@ -0,0 +1,10 @@ +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + root: props => ({ color: props.active ? theme.palette.primary.main : theme.palette.text.secondary }), +})); + +const MyComponent = (props: any) => { + const classes = useStyles(props); + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/metrics.json b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/metrics.json new file mode 100644 index 0000000..6fded89 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/dynamic-rule-todo/metrics.json @@ -0,0 +1,17 @@ +{ + "migrate-mui-styles-to-bui-css-modules": [ + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "dynamic-rules" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..4204151 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,11 @@ +import styles from './input.module.css'; +import styles from './input.module.css'; + + + + + +const MyComponent = () => { + + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/input.module.css b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/input.module.css new file mode 100644 index 0000000..debba87 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/input.module.css @@ -0,0 +1,3 @@ +.container { + display: block; +} diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..e40a945 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/input.tsx @@ -0,0 +1,14 @@ +import styles from './input.module.css'; +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles({ + wrapper: { + padding: 0, + margin: 0, + }, +}); + +const MyComponent = () => { + const classes = useStyles(); + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..530a753 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/metrics.json @@ -0,0 +1,40 @@ +{ + "migrate-mui-styles-to-bui-css-modules": [ + { + "cardinality": { + "action": "classes-ref-replaced" + }, + "count": 1 + }, + { + "cardinality": { + "action": "css-module-file-written" + }, + "count": 1 + }, + { + "cardinality": { + "action": "css-module-import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "hook-call-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "makeStyles-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/expected.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/expected.tsx new file mode 100644 index 0000000..30cedfd --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/expected.tsx @@ -0,0 +1,14 @@ +import styles from './input.module.css'; + + + + +const MyComponent = () => { + + return ( +
+

Title

+

Content

+
+ ); +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/input.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/input.tsx new file mode 100644 index 0000000..e6279ec --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/input.tsx @@ -0,0 +1,22 @@ +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + header: { + color: theme.palette.text.primary, + padding: theme.spacing(1), + }, + body: { + color: theme.palette.text.secondary, + backgroundColor: theme.palette.background.default, + }, +})); + +const MyComponent = () => { + const classes = useStyles(); + return ( +
+

Title

+

Content

+
+ ); +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/metrics.json b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/metrics.json new file mode 100644 index 0000000..427474e --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/multiple-classes/metrics.json @@ -0,0 +1,40 @@ +{ + "migrate-mui-styles-to-bui-css-modules": [ + { + "cardinality": { + "action": "classes-ref-replaced" + }, + "count": 2 + }, + { + "cardinality": { + "action": "css-module-file-written" + }, + "count": 1 + }, + { + "cardinality": { + "action": "css-module-import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "hook-call-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "makeStyles-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..bccf4e0 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import styles from './MyComponent.module.css'; + +const MyComponent = () => { + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..bccf4e0 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import styles from './MyComponent.module.css'; + +const MyComponent = () => { + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/expected.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/expected.tsx new file mode 100644 index 0000000..b8fd1ff --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/expected.tsx @@ -0,0 +1,9 @@ +import styles from './input.module.css'; + + + + +const MyComponent = () => { + + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/input.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/input.tsx new file mode 100644 index 0000000..a712fdd --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/input.tsx @@ -0,0 +1,14 @@ +import { makeStyles } from '@material-ui/core/styles'; + +const useStyles = makeStyles(theme => ({ + container: { + padding: theme.spacing(2), + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + }, +})); + +const MyComponent = () => { + const classes = useStyles(); + return
; +}; diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/metrics.json b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/metrics.json new file mode 100644 index 0000000..530a753 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/static-theme-tokens/metrics.json @@ -0,0 +1,40 @@ +{ + "migrate-mui-styles-to-bui-css-modules": [ + { + "cardinality": { + "action": "classes-ref-replaced" + }, + "count": 1 + }, + { + "cardinality": { + "action": "css-module-file-written" + }, + "count": 1 + }, + { + "cardinality": { + "action": "css-module-import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "hook-call-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "makeStyles-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/expected.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/expected.tsx new file mode 100644 index 0000000..68217aa --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/expected.tsx @@ -0,0 +1,6 @@ +import { withStyles } from '@material-ui/core/styles'; + +// TODO(backstage-codemod): migrate withStyles to CSS Modules manually +const StyledDiv = withStyles({ + root: { padding: 16 }, +})('div'); diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/input.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/input.tsx new file mode 100644 index 0000000..54f2c40 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/input.tsx @@ -0,0 +1,5 @@ +import { withStyles } from '@material-ui/core/styles'; + +const StyledDiv = withStyles({ + root: { padding: 16 }, +})('div'); diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/metrics.json b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/metrics.json new file mode 100644 index 0000000..1fd9bb7 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/with-styles-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-styles-to-bui-css-modules": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "withStyles" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tsconfig.json b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/workflow.yaml b/codemods/misc/migrate-mui-styles-to-bui-css-modules/workflow.yaml new file mode 100644 index 0000000..ec04ef3 --- /dev/null +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/workflow.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Apply AST Transformations' + type: automatic + steps: + - name: 'Migrate makeStyles usage to BUI CSS modules' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' diff --git a/yarn.lock b/yarn.lock index afce4d8..4b62a40 100644 --- a/yarn.lock +++ b/yarn.lock @@ -172,6 +172,42 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-bootstrap-to-bui@workspace:codemods/misc/migrate-mui-bootstrap-to-bui": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-bootstrap-to-bui@workspace:codemods/misc/migrate-mui-bootstrap-to-bui" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-icons-to-remix-icons@workspace:codemods/misc/migrate-mui-icons-to-remix-icons": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-icons-to-remix-icons@workspace:codemods/misc/migrate-mui-icons-to-remix-icons" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-layout-to-bui-layout@workspace:codemods/misc/migrate-mui-layout-to-bui-layout": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-layout-to-bui-layout@workspace:codemods/misc/migrate-mui-layout-to-bui-layout" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-styles-to-bui-css-modules@workspace:codemods/misc/migrate-mui-styles-to-bui-css-modules": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-styles-to-bui-css-modules@workspace:codemods/misc/migrate-mui-styles-to-bui-css-modules" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page": version: 0.0.0-use.local resolution: "@backstage/migrate-nav-item-to-page@workspace:codemods/v1.51.0/migrate-nav-item-to-page" From 88b1d8fb00d9e65635bb7a0fb1b4522c06737bbb Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:22:13 -0500 Subject: [PATCH 02/12] fix: resolve lint warnings in foundation MUI-to-BUI codemods Use Promise.resolve returns, array destructuring, and find() to satisfy oxlint with --deny-warnings in CI. Co-authored-by: Cursor --- .../scripts/codemod.ts | 14 +++++----- .../scripts/package-json-codemod.ts | 12 ++++----- .../scripts/codemod.ts | 6 ++--- .../scripts/codemod.ts | 8 +++--- .../scripts/codemod.ts | 27 ++++++++++++------- 5 files changed, 37 insertions(+), 30 deletions(-) diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts index fc0e326..44007f9 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts @@ -31,37 +31,37 @@ function hasBuiCssImport(rootNode: SgNode): boolean { return cssImports.length > 0 } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] // Only process files that contain @material-ui imports if (!hasMuiImports(rootNode)) { - return null + return Promise.resolve(null) } // Skip if the BUI CSS import is already present if (hasBuiCssImport(rootNode)) { migrationMetric.increment({ action: 'already-bootstrapped' }) - return null + return Promise.resolve(null) } // Find the first import statement to insert before it const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) if (allImports.length === 0) { - return null + return Promise.resolve(null) } - const firstImport = allImports[0] + const [firstImport] = allImports if (!firstImport) { - return null + return Promise.resolve(null) } // Insert the BUI CSS import before the first import edits.push(firstImport.replace(`import '${BUI_CSS_IMPORT}';\n${firstImport.text()}`)) migrationMetric.increment({ action: 'css-import-added' }) - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts index 6890747..2980557 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts @@ -55,7 +55,7 @@ function dependencySectionForMui(pkg: PackageJson): 'dependencies' | 'devDepende return 'devDependencies' } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const source = normalizeSource(rootNode.text()) @@ -63,11 +63,11 @@ const transform: Codemod = async (root) => { try { pkg = globalThis.JSON.parse(source) as PackageJson } catch { - return null + return Promise.resolve(null) } if (!hasMuiDependency(pkg)) { - return null + return Promise.resolve(null) } const section = dependencySectionForMui(pkg) @@ -88,7 +88,7 @@ const transform: Codemod = async (root) => { if (!changed) { migrationMetric.increment({ action: 'already-bootstrapped-deps' }) - return null + return Promise.resolve(null) } pkg[section] = sortObjectKeys(existingDeps) @@ -98,10 +98,10 @@ const transform: Codemod = async (root) => { const result = `${globalThis.JSON.stringify(pkg, null, indent)}\n` if (result === source) { - return null + return Promise.resolve(null) } - return result + return Promise.resolve(result) } export default transform diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts index 0ef097a..429cffc 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts @@ -372,14 +372,14 @@ function transformExtensionIconSlots(rootNode: SgNode, iconLocalNames: Set< } } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { icons, namespaceImports } = collectIconImports(rootNode) if (icons.length === 0 && namespaceImports.length === 0) { - return null + return Promise.resolve(null) } for (const nsImp of namespaceImports) { @@ -419,7 +419,7 @@ const transform: Codemod = async (root) => { transformIconJsx(rootNode, iconLocalNames, edits) transformExtensionIconSlots(rootNode, iconLocalNames, edits) - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts index fc56493..060df7e 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts @@ -163,7 +163,7 @@ function removeUnusedLayoutImports( const identifiers = spec.findAll({ rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, }) - const importedNameNode = identifiers[0] + const [importedNameNode] = identifiers if (!importedNameNode) { return true } @@ -672,14 +672,14 @@ function transformLayoutElements(rootNode: SgNode, localNames: Map = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { localNames, importNodes } = collectLayoutImports(rootNode) if (localNames.size === 0) { - return null + return Promise.resolve(null) } // Transform elements before removing imports so TODO paths keep MUI imports. @@ -691,7 +691,7 @@ const transform: Codemod = async (root) => { addBuiImport(rootNode, [...usedBuiNames], edits) } - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts index 5bc306f..dabcaff 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts @@ -172,9 +172,7 @@ function extractCssRules(styleObjNode: SgNode): { rules: CssRule[]; hasDyna // Get the value — skip key and colon const valueNode = child .children() - .filter( - (c) => c.kind() !== 'property_identifier' && c.kind() !== ':' && c.kind() !== 'string' && c.kind() !== ',', - )[0] + .find((c) => c.kind() !== 'property_identifier' && c.kind() !== ':' && c.kind() !== 'string' && c.kind() !== ',') if (!valueNode) { continue @@ -217,14 +215,14 @@ function extractCssRules(styleObjNode: SgNode): { rules: CssRule[]; hasDyna const propValueNode = propPair .children() - .filter( + .find( (c) => c.kind() !== 'property_identifier' && c.kind() !== ':' && c.kind() !== 'string' && c.kind() !== ',' && c.kind() !== propKeyNode.kind(), - )[0] + ) if (!propValueNode) { ruleHasDynamic = true @@ -300,7 +298,7 @@ function deriveCssModuleFilePath(filename: string): string { } async function writeCssModuleFile(cssFilePath: string, content: string): Promise { - const writeCss: Codemod = async () => content + const writeCss: Codemod = () => Promise.resolve(content) await jssgTransform(writeCss, cssFilePath, 'css') } @@ -348,6 +346,12 @@ const transform: Codemod = async (root) => { } } + if (!makeStylesLocal) { + return edits.length > 0 ? rootNode.commitEdits(edits) : null + } + + const makeStylesPattern = escapeRegex(makeStylesLocal) + // Find the makeStyles call: const useStyles = makeStyles(...) const makeStylesDeclarations = rootNode.findAll({ rule: { @@ -359,7 +363,7 @@ const transform: Codemod = async (root) => { has: { field: 'function', kind: 'identifier', - regex: `^${escapeRegex(makeStylesLocal!)}$`, + regex: `^${makeStylesPattern}$`, }, }, }, @@ -387,7 +391,7 @@ const transform: Codemod = async (root) => { has: { field: 'function', kind: 'identifier', - regex: `^${escapeRegex(makeStylesLocal!)}$`, + regex: `^${makeStylesPattern}$`, }, }, }) @@ -470,7 +474,7 @@ const transform: Codemod = async (root) => { }) } } else { - const firstNode = rootNode.children()[0] + const [firstNode] = rootNode.children() if (firstNode) { edits.push({ startPos: firstNode.range().start.index, @@ -502,7 +506,10 @@ const transform: Codemod = async (root) => { for (const hookCall of hookCalls) { const hookDeclarator = hookCall.find({ rule: { kind: 'variable_declarator' } }) - const classesName = hookDeclarator?.field('name')?.text() + if (!hookDeclarator) { + continue + } + const classesName = hookDeclarator.field('name')?.text() if (classesName) { // Replace all classes.X references with styles.X From df8cfe0e2b0da585a9a4cc98f66033924f6b7342 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:27:12 -0500 Subject: [PATCH 03/12] docs: regenerate README for foundation MUI-to-BUI codemods Co-authored-by: Cursor --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index b200fef..07735b1 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,15 @@ Run the [`migration-recipe`](./codemods/v1.51.0/migration-recipe) to apply every Older versions are available in the [`codemods/`](./codemods) directory. +### misc + +| Codemod | Description | +| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| [migrate-mui-bootstrap-to-bui](./codemods/misc/migrate-mui-bootstrap-to-bui) | MUI 4 to BUI: Bootstrap app dependencies and root CSS | +| [migrate-mui-icons-to-remix-icons](./codemods/misc/migrate-mui-icons-to-remix-icons) | MUI 4 to BUI: Replace MUI icons with Remix icons | +| [migrate-mui-layout-to-bui-layout](./codemods/misc/migrate-mui-layout-to-bui-layout) | MUI 4 to BUI: Convert common MUI layout primitives to BUI layout | +| [migrate-mui-styles-to-bui-css-modules](./codemods/misc/migrate-mui-styles-to-bui-css-modules) | MUI 4 to BUI: Migrate makeStyles usage to BUI CSS modules | + ## Usage From a6eec829735e16a5babbe37dab3f8dde772f7d1c Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:53:50 -0500 Subject: [PATCH 04/12] fix: address Copilot review feedback on foundation codemods Co-authored-by: Cursor --- .../scripts/codemod.ts | 46 ++++++- .../tests/already-bootstrapped/expected.tsx | 4 +- .../tests/already-bootstrapped/input.tsx | 4 +- .../tests/icons-import/expected.tsx | 1 - .../tests/icons-import/metrics.json | 10 -- .../tests/merge-existing-bui/expected.tsx | 4 +- .../tests/merge-existing-bui/input.tsx | 4 +- .../tests/multiple-mui-imports/expected.tsx | 1 - .../tests/multiple-mui-imports/metrics.json | 10 -- .../scripts/codemod.ts | 28 ++-- .../tests/aliased-import/expected.tsx | 1 + .../tests/extension-icon-slot/expected.tsx | 1 + .../tests/multiple-icons/expected.tsx | 1 + .../tests/simple-known-icon/expected.tsx | 1 + .../scripts/codemod.ts | 61 ++++++--- .../tests/box-flex-container/expected.tsx | 2 +- .../tests/box-flex-container/metrics.json | 6 + .../tests/grid-simple/expected.tsx | 2 +- .../tests/grid-simple/metrics.json | 6 + .../tests/merge-existing-bui/expected.tsx | 2 +- .../tests/merge-existing-bui/metrics.json | 6 + .../tests/paper-simple/expected.tsx | 2 +- .../tests/paper-simple/metrics.json | 6 + .../scripts/codemod.ts | 123 +++++++++++++++--- .../tests/merge-existing-bui/expected.tsx | 2 - .../tests/merge-existing-bui/metrics.json | 6 - 26 files changed, 250 insertions(+), 90 deletions(-) delete mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/metrics.json delete mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/metrics.json diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts index 44007f9..08a8182 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts @@ -21,6 +21,46 @@ function findImportStatementsMatching(rootNode: SgNode, pattern: string): S }) } +function normalizeFilePath(filename: string): string { + return filename.replace(/^\\\\\?\\/, '').replaceAll('\\', '/') +} + +/** + * Match Backstage app/plugin entry files where the global BUI stylesheet belongs. + */ +function isAppEntryFile(filename: string, rootNode: SgNode): boolean { + const normalized = normalizeFilePath(filename) + + if (/(?:^|\/)App\.tsx?$/.test(normalized)) { + return true + } + if (/(?:^|\/)src\/index\.tsx?$/.test(normalized)) { + return true + } + if (/(?:^|\/)src\/plugin\.tsx?$/.test(normalized)) { + return true + } + + // Typical app bootstrap entry when the filename is index.tsx content (e.g. jssg fixtures). + const createRootImports = findImportStatementsMatching(rootNode, '^react-dom/client$') + for (const imp of createRootImports) { + const createRootSpecifier = imp.find({ + rule: { + kind: 'import_specifier', + has: { + kind: 'identifier', + regex: '^createRoot$', + }, + }, + }) + if (createRootSpecifier) { + return true + } + } + + return false +} + function hasMuiImports(rootNode: SgNode): boolean { const muiImports = findImportStatementsMatching(rootNode, '^@material-ui/') return muiImports.length > 0 @@ -35,7 +75,11 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - // Only process files that contain @material-ui imports + if (!isAppEntryFile(root.filename(), rootNode)) { + return Promise.resolve(null) + } + + // Only process entry files that contain @material-ui imports if (!hasMuiImports(rootNode)) { return Promise.resolve(null) } diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/expected.tsx index c15aa62..2135ba0 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/expected.tsx +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/expected.tsx @@ -1,5 +1,5 @@ import '@backstage/ui/css/styles.css'; import React from 'react'; +import { createRoot } from 'react-dom/client'; import { Typography } from '@material-ui/core'; - -const Page = () => Hello; +import { App } from './App'; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/input.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/input.tsx index c15aa62..2135ba0 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/input.tsx +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/already-bootstrapped/input.tsx @@ -1,5 +1,5 @@ import '@backstage/ui/css/styles.css'; import React from 'react'; +import { createRoot } from 'react-dom/client'; import { Typography } from '@material-ui/core'; - -const Page = () => Hello; +import { App } from './App'; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/expected.tsx index 566d7b6..3278307 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/expected.tsx +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/expected.tsx @@ -1,4 +1,3 @@ -import '@backstage/ui/css/styles.css'; import DeleteIcon from '@material-ui/icons/Delete'; const MyComponent = () => ; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/metrics.json deleted file mode 100644 index 8ee4de4..0000000 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/icons-import/metrics.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "migrate-mui-bootstrap-to-bui": [ - { - "cardinality": { - "action": "css-import-added" - }, - "count": 1 - } - ] -} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/expected.tsx index c15aa62..2135ba0 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/expected.tsx @@ -1,5 +1,5 @@ import '@backstage/ui/css/styles.css'; import React from 'react'; +import { createRoot } from 'react-dom/client'; import { Typography } from '@material-ui/core'; - -const Page = () => Hello; +import { App } from './App'; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/input.tsx index c15aa62..2135ba0 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/input.tsx +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/merge-existing-bui/input.tsx @@ -1,5 +1,5 @@ import '@backstage/ui/css/styles.css'; import React from 'react'; +import { createRoot } from 'react-dom/client'; import { Typography } from '@material-ui/core'; - -const Page = () => Hello; +import { App } from './App'; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/expected.tsx b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/expected.tsx index 20444d6..c998d82 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/expected.tsx +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/expected.tsx @@ -1,4 +1,3 @@ -import '@backstage/ui/css/styles.css'; import React from 'react'; import Alert from '@material-ui/lab/Alert'; import { Button, Typography } from '@material-ui/core'; diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/metrics.json deleted file mode 100644 index 8ee4de4..0000000 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/multiple-mui-imports/metrics.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "migrate-mui-bootstrap-to-bui": [ - { - "cardinality": { - "action": "css-import-added" - }, - "count": 1 - } - ] -} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts index 429cffc..548b4a6 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts @@ -214,7 +214,12 @@ function collectIconImports(rootNode: SgNode): { return { icons, namespaceImports } } -function addRemixImports(rootNode: SgNode, remixImports: Map, edits: Edit[]): void { +function addRemixImports( + rootNode: SgNode, + remixImports: Map, + excludedImportIds: Set, + edits: Edit[], +): void { if (remixImports.size === 0) { return } @@ -251,13 +256,16 @@ function addRemixImports(rootNode: SgNode, remixImports: Map 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push( - lastImport.replace(`${lastImport.text()}\nimport { ${specifiers.join(', ')} } from '${REMIX_SOURCE}';`), - ) - } + const anchorImport = + [...allImports].reverse().find((imp) => !excludedImportIds.has(imp.id())) ?? allImports.at(-1) ?? null + + if (anchorImport) { + const insertAt = anchorImport.range().end.index + edits.push({ + startPos: insertAt, + endPos: insertAt, + insertedText: `\nimport { ${specifiers.join(', ')} } from '${REMIX_SOURCE}';`, + }) } } @@ -394,6 +402,7 @@ const transform: Codemod = (root) => { const remixImports = new Map() const iconLocalNames = new Set() const processedImportIds = new Set() + const excludedImportIds = new Set() for (const icon of icons) { if (icon.remixName) { @@ -401,6 +410,7 @@ const transform: Codemod = (root) => { iconLocalNames.add(icon.localName) if (!processedImportIds.has(icon.importNode.id())) { + excludedImportIds.add(icon.importNode.id()) edits.push(icon.importNode.replace('')) processedImportIds.add(icon.importNode.id()) } @@ -415,7 +425,7 @@ const transform: Codemod = (root) => { } } - addRemixImports(rootNode, remixImports, edits) + addRemixImports(rootNode, remixImports, excludedImportIds, edits) transformIconJsx(rootNode, iconLocalNames, edits) transformExtensionIconSlots(rootNode, iconLocalNames, edits) diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/expected.tsx index a84559b..3e5b3d1 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/expected.tsx +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/aliased-import/expected.tsx @@ -1,4 +1,5 @@ +import { RiDeleteBinLine as MyDeleteIcon } from '@remixicon/react'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx index 592dfb1..7111c27 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx @@ -1,4 +1,5 @@ +import { RiSearchLine as SearchIcon } from '@remixicon/react'; const navItem = { title: 'Search', diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/expected.tsx index 7d3dc7e..f6416a7 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/expected.tsx +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/multiple-icons/expected.tsx @@ -1,5 +1,6 @@ +import { RiCloseLine as CloseIcon, RiSearchLine as SearchIcon } from '@remixicon/react'; const MyComponent = () => ( <> diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/expected.tsx index 3ee2826..03d731e 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/expected.tsx +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/simple-known-icon/expected.tsx @@ -1,4 +1,5 @@ +import { RiSearchLine as SearchIcon } from '@remixicon/react'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts index 060df7e..547bc86 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts @@ -106,7 +106,11 @@ function collectLayoutImports(rootNode: SgNode): LayoutImports { return { localNames, importNodes } } -function isMuiComponentStillUsed(rootNode: SgNode, localName: string): boolean { +function isMuiComponentStillUsed(rootNode: SgNode, localName: string, migratedLocalNames: Set): boolean { + if (migratedLocalNames.has(localName)) { + return false + } + const jsxElements = rootNode.findAll({ rule: { any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], @@ -134,9 +138,11 @@ function removeUnusedLayoutImports( rootNode: SgNode, localNames: Map, importNodes: SgNode[], + migratedLocalNames: Set, edits: Edit[], -): void { +): Set { const seenImportIds = new Set() + const removedImportIds = new Set() for (const imp of importNodes) { if (seenImportIds.has(imp.id())) { @@ -148,14 +154,19 @@ function removeUnusedLayoutImports( const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) if (defaultName && allSpecifiers.length === 0) { - if (localNames.has(defaultName) && !isMuiComponentStillUsed(rootNode, defaultName)) { + if (localNames.has(defaultName) && !isMuiComponentStillUsed(rootNode, defaultName, migratedLocalNames)) { edits.push(imp.replace('')) + removedImportIds.add(imp.id()) migrationMetric.increment({ action: 'import-removed' }) } continue } - if (defaultName && localNames.has(defaultName) && !isMuiComponentStillUsed(rootNode, defaultName)) { + if ( + defaultName && + localNames.has(defaultName) && + !isMuiComponentStillUsed(rootNode, defaultName, migratedLocalNames) + ) { // Default + named imports: drop the default binding when unused; trim named below. } @@ -172,11 +183,12 @@ function removeUnusedLayoutImports( if (!localNames.has(localName)) { return true } - return isMuiComponentStillUsed(rootNode, localName) + return isMuiComponentStillUsed(rootNode, localName, migratedLocalNames) }) if (remainingSpecifiers.length === 0) { edits.push(imp.replace('')) + removedImportIds.add(imp.id()) migrationMetric.increment({ action: 'import-removed' }) } else if (remainingSpecifiers.length < allSpecifiers.length) { const namedImports = imp.find({ rule: { kind: 'named_imports' } }) @@ -186,9 +198,11 @@ function removeUnusedLayoutImports( } } } + + return removedImportIds } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport(rootNode: SgNode, names: string[], excludedImportIds: Set, edits: Edit[]): void { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null @@ -213,13 +227,16 @@ function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): vo } else { const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) const sortedNames = [...names].sort() - if (allImports.length > 0) { - const lastImport = allImports.at(-1) - if (lastImport) { - edits.push( - lastImport.replace(`${lastImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), - ) - } + const anchorImport = + [...allImports].reverse().find((imp) => !excludedImportIds.has(imp.id())) ?? allImports.at(-1) ?? null + + if (anchorImport) { + const insertAt = anchorImport.range().end.index + edits.push({ + startPos: insertAt, + endPos: insertAt, + insertedText: `\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`, + }) } migrationMetric.increment({ action: 'import-added' }) } @@ -618,8 +635,13 @@ function transformGridElement(el: SgNode, opening: SgNode, edits: Edit return 'Grid' } -function transformLayoutElements(rootNode: SgNode, localNames: Map, edits: Edit[]): Set { +function transformLayoutElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], +): { usedBuiNames: Set; migratedLocalNames: Set } { const usedBuiNames = new Set() + const migratedLocalNames = new Set() const jsxElements = rootNode.findAll({ rule: { @@ -648,6 +670,7 @@ function transformLayoutElements(rootNode: SgNode, localNames: Map, localNames: Map, localNames: Map = (root) => { @@ -683,12 +708,12 @@ const transform: Codemod = (root) => { } // Transform elements before removing imports so TODO paths keep MUI imports. - const usedBuiNames = transformLayoutElements(rootNode, localNames, edits) + const { usedBuiNames, migratedLocalNames } = transformLayoutElements(rootNode, localNames, edits) - removeUnusedLayoutImports(rootNode, localNames, importNodes, edits) + const removedImportIds = removeUnusedLayoutImports(rootNode, localNames, importNodes, migratedLocalNames, edits) if (usedBuiNames.size > 0) { - addBuiImport(rootNode, [...usedBuiNames], edits) + addBuiImport(rootNode, [...usedBuiNames], removedImportIds, edits) } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/expected.tsx index 5acbe48..a21c885 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/expected.tsx +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/expected.tsx @@ -1,4 +1,4 @@ -import Box from '@material-ui/core/Box'; + import { Flex } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/metrics.json index 081027b..0944fbb 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/metrics.json +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/box-flex-container/metrics.json @@ -11,6 +11,12 @@ "action": "import-added" }, "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx index eff0dab..3bc4c2a 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx @@ -1,4 +1,4 @@ -import Grid from '@material-ui/core/Grid'; + import { Grid } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/metrics.json index 6b637b8..ae09607 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/metrics.json +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/metrics.json @@ -17,6 +17,12 @@ "action": "import-added" }, "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/expected.tsx index ecfbd5f..3933483 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/expected.tsx @@ -1,4 +1,4 @@ -import Box from '@material-ui/core/Box'; + import { Flex, Grid } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/metrics.json index 0cc6fb0..4ad3ec9 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/metrics.json +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/merge-existing-bui/metrics.json @@ -11,6 +11,12 @@ "action": "import-merged" }, "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx index b1a9e23..960f3ba 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/expected.tsx @@ -1,4 +1,4 @@ -import Paper from '@material-ui/core/Paper'; + import { Surface } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json index 381a2ce..e2f1acc 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/paper-simple/metrics.json @@ -6,6 +6,12 @@ }, "count": 1 }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, { "cardinality": { "action": "paper-to-surface" diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts index dabcaff..4d0be04 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts @@ -297,8 +297,89 @@ function deriveCssModuleFilePath(filename: string): string { return normalized.replace(/\.[^.]+$/, '.module.css') } +function hasCssModuleImport(rootNode: SgNode, cssModuleImportPath: string): boolean { + const imports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + for (const imp of imports) { + const stringFrag = imp.find({ rule: { kind: 'string_fragment' } }) + if (stringFrag?.text() === cssModuleImportPath) { + return true + } + } + return false +} + +async function readCssModuleFile(cssFilePath: string): Promise { + try { + let existing = '' + const readCss: Codemod = (root) => { + existing = root.root().text() + return Promise.resolve(null) + } + await jssgTransform(readCss, cssFilePath, 'css') + return existing.trim() ? existing : null + } catch { + return null + } +} + +function extractClassNamesFromCss(cssContent: string): Set { + const classNames = new Set() + for (const match of cssContent.matchAll(/\.([A-Za-z0-9_-]+)\s*\{/g)) { + const [, className] = match + if (className) { + classNames.add(className) + } + } + return classNames +} + +function extractCssModuleRuleBlocks(cssContent: string): { className: string; block: string }[] { + const rules: { className: string; block: string }[] = [] + for (const match of cssContent.matchAll(/\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}/g)) { + const [, className, ruleBody] = match + if (!className) { + continue + } + rules.push({ + className, + block: `.${className} {${ruleBody}}`, + }) + } + return rules +} + +function mergeCssModuleContent(existing: string, generated: string): string { + const trimmedExisting = existing.trimEnd() + if (!trimmedExisting) { + return `${generated}\n` + } + + const existingClassNames = extractClassNamesFromCss(trimmedExisting) + const newRules = extractCssModuleRuleBlocks(generated).filter((rule) => !existingClassNames.has(rule.className)) + if (newRules.length === 0) { + return `${trimmedExisting}\n` + } + + const formattedNewRules = newRules.map((rule) => + rule.block + .split('\n') + .map((line) => (line ? ` ${line}` : line)) + .join('\n'), + ) + + const layerMatch = trimmedExisting.match(/^([\s\S]*@layer\s+components\s*\{)([\s\S]*?)(\}\s*)$/) + if (layerMatch) { + const [, layerOpen, layerInner, layerClose] = layerMatch + return `${layerOpen}${layerInner}\n${formattedNewRules.join('\n')}\n${layerClose}\n` + } + + return `${trimmedExisting}\n\n@layer components {\n${formattedNewRules.join('\n')}\n}\n` +} + async function writeCssModuleFile(cssFilePath: string, content: string): Promise { - const writeCss: Codemod = () => Promise.resolve(content) + const existing = await readCssModuleFile(cssFilePath) + const mergedContent = existing ? mergeCssModuleContent(existing, content) : content + const writeCss: Codemod = () => Promise.resolve(mergedContent) await jssgTransform(writeCss, cssFilePath, 'css') } @@ -463,27 +544,29 @@ const transform: Codemod = async (root) => { .filter((imp) => !importsToRemoveIds.has(imp.id())) const cssImportLine = `import styles from '${cssModuleImportPath}';\n` - if (survivingImports.length > 0) { - const lastSurvivingImport = survivingImports.at(-1) - if (lastSurvivingImport) { - const insertAt = lastSurvivingImport.range().end.index - edits.push({ - startPos: insertAt, - endPos: insertAt, - insertedText: `\n${cssImportLine}`, - }) - } - } else { - const [firstNode] = rootNode.children() - if (firstNode) { - edits.push({ - startPos: firstNode.range().start.index, - endPos: firstNode.range().start.index, - insertedText: cssImportLine, - }) + if (!hasCssModuleImport(rootNode, cssModuleImportPath)) { + if (survivingImports.length > 0) { + const lastSurvivingImport = survivingImports.at(-1) + if (lastSurvivingImport) { + const insertAt = lastSurvivingImport.range().end.index + edits.push({ + startPos: insertAt, + endPos: insertAt, + insertedText: `\n${cssImportLine}`, + }) + } + } else { + const [firstNode] = rootNode.children() + if (firstNode) { + edits.push({ + startPos: firstNode.range().start.index, + endPos: firstNode.range().start.index, + insertedText: cssImportLine, + }) + } } + migrationMetric.increment({ action: 'css-module-import-added' }) } - migrationMetric.increment({ action: 'css-module-import-added' }) migrationMetric.increment({ action: 'css-module-file-written' }) // Find and remove the useStyles() hook call diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/expected.tsx index 4204151..6b92040 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/expected.tsx +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/expected.tsx @@ -1,6 +1,4 @@ import styles from './input.module.css'; -import styles from './input.module.css'; - diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/metrics.json index 530a753..bae3378 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/metrics.json +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/tests/merge-existing-bui/metrics.json @@ -12,12 +12,6 @@ }, "count": 1 }, - { - "cardinality": { - "action": "css-module-import-added" - }, - "count": 1 - }, { "cardinality": { "action": "hook-call-removed" From 2d49be230c12eeb3b22762c6ef96d5bda163a163 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 10:29:17 -0500 Subject: [PATCH 05/12] feat: add remove-mui-dependencies cleanup codemod Scan source files for remaining @material-ui/* imports, then remove unused MUI packages from each package.json. Intended as the final step after component migration codemods complete. Co-authored-by: Cursor --- .changeset/mui-to-bui-foundation.md | 3 +- README.md | 13 +- .../misc/remove-mui-dependencies/.gitignore | 33 +++ .../misc/remove-mui-dependencies/CHANGELOG.md | 7 + .../misc/remove-mui-dependencies/codemod.yaml | 20 ++ .../misc/remove-mui-dependencies/package.json | 13 + .../scripts/codemod.ts | 237 ++++++++++++++++++ .../scripts/scan-mui-usage.ts | 66 +++++ .../tests/no-mui-deps/expected.json | 7 + .../tests/no-mui-deps/input.json | 7 + .../tests/removes-all-mui-deps/expected.json | 7 + .../tests/removes-all-mui-deps/input.json | 10 + .../tests/removes-all-mui-deps/metrics.json | 25 ++ .../expected.json | 6 + .../removes-from-dev-dependencies/input.json | 7 + .../metrics.json | 11 + .../scan-detects-core-import/expected.tsx | 3 + .../tests/scan-detects-core-import/input.tsx | 3 + .../scan-detects-core-import/metrics.json | 11 + .../remove-mui-dependencies/tsconfig.json | 16 ++ .../remove-mui-dependencies/workflow.yaml | 27 ++ yarn.lock | 9 + 22 files changed, 534 insertions(+), 7 deletions(-) create mode 100644 codemods/misc/remove-mui-dependencies/.gitignore create mode 100644 codemods/misc/remove-mui-dependencies/CHANGELOG.md create mode 100644 codemods/misc/remove-mui-dependencies/codemod.yaml create mode 100644 codemods/misc/remove-mui-dependencies/package.json create mode 100644 codemods/misc/remove-mui-dependencies/scripts/codemod.ts create mode 100644 codemods/misc/remove-mui-dependencies/scripts/scan-mui-usage.ts create mode 100644 codemods/misc/remove-mui-dependencies/tests/no-mui-deps/expected.json create mode 100644 codemods/misc/remove-mui-dependencies/tests/no-mui-deps/input.json create mode 100644 codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/expected.json create mode 100644 codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/input.json create mode 100644 codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/metrics.json create mode 100644 codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/expected.json create mode 100644 codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/input.json create mode 100644 codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/metrics.json create mode 100644 codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/expected.tsx create mode 100644 codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/input.tsx create mode 100644 codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/metrics.json create mode 100644 codemods/misc/remove-mui-dependencies/tsconfig.json create mode 100644 codemods/misc/remove-mui-dependencies/workflow.yaml diff --git a/.changeset/mui-to-bui-foundation.md b/.changeset/mui-to-bui-foundation.md index 8934e64..b52ffcb 100644 --- a/.changeset/mui-to-bui-foundation.md +++ b/.changeset/mui-to-bui-foundation.md @@ -3,6 +3,7 @@ '@backstage/migrate-mui-icons-to-remix-icons': minor '@backstage/migrate-mui-styles-to-bui-css-modules': minor '@backstage/migrate-mui-layout-to-bui-layout': minor +'@backstage/remove-mui-dependencies': minor --- -Add foundation codemods for the MUI 4 to BUI migration: bootstrap app dependencies and root CSS, replace MUI icons with Remix icons, migrate makeStyles to CSS modules, and convert layout primitives to BUI equivalents. +Add foundation codemods for the MUI 4 to BUI migration: bootstrap app dependencies and root CSS, replace MUI icons with Remix icons, migrate makeStyles to CSS modules, convert layout primitives to BUI equivalents, and remove unused @material-ui/\* dependencies from package.json after migration. diff --git a/README.md b/README.md index 07735b1..ee1aae1 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,13 @@ Older versions are available in the [`codemods/`](./codemods) directory. ### misc -| Codemod | Description | -| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -| [migrate-mui-bootstrap-to-bui](./codemods/misc/migrate-mui-bootstrap-to-bui) | MUI 4 to BUI: Bootstrap app dependencies and root CSS | -| [migrate-mui-icons-to-remix-icons](./codemods/misc/migrate-mui-icons-to-remix-icons) | MUI 4 to BUI: Replace MUI icons with Remix icons | -| [migrate-mui-layout-to-bui-layout](./codemods/misc/migrate-mui-layout-to-bui-layout) | MUI 4 to BUI: Convert common MUI layout primitives to BUI layout | -| [migrate-mui-styles-to-bui-css-modules](./codemods/misc/migrate-mui-styles-to-bui-css-modules) | MUI 4 to BUI: Migrate makeStyles usage to BUI CSS modules | +| Codemod | Description | +| ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| [migrate-mui-bootstrap-to-bui](./codemods/misc/migrate-mui-bootstrap-to-bui) | MUI 4 to BUI: Bootstrap app dependencies and root CSS | +| [migrate-mui-icons-to-remix-icons](./codemods/misc/migrate-mui-icons-to-remix-icons) | MUI 4 to BUI: Replace MUI icons with Remix icons | +| [migrate-mui-layout-to-bui-layout](./codemods/misc/migrate-mui-layout-to-bui-layout) | MUI 4 to BUI: Convert common MUI layout primitives to BUI layout | +| [migrate-mui-styles-to-bui-css-modules](./codemods/misc/migrate-mui-styles-to-bui-css-modules) | MUI 4 to BUI: Migrate makeStyles usage to BUI CSS modules | +| [remove-mui-dependencies](./codemods/misc/remove-mui-dependencies) | MUI 4 to BUI: Remove unused @material-ui/\* dependencies from package.json | diff --git a/codemods/misc/remove-mui-dependencies/.gitignore b/codemods/misc/remove-mui-dependencies/.gitignore new file mode 100644 index 0000000..a84ef48 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/.gitignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build artifacts +target/ +dist/ +build/ + +# Temporary files +*.tmp +*.temp +.cache/ + +# Environment files +.env +.env.local + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Package bundles +*.tar.gz +*.tgz diff --git a/codemods/misc/remove-mui-dependencies/CHANGELOG.md b/codemods/misc/remove-mui-dependencies/CHANGELOG.md new file mode 100644 index 0000000..c93e20e --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/CHANGELOG.md @@ -0,0 +1,7 @@ +# @backstage/remove-mui-dependencies + +## 0.1.0 + +### Minor Changes + +- Initial release: remove unused `@material-ui/*` dependencies from `package.json` after source files in the same package no longer import them. diff --git a/codemods/misc/remove-mui-dependencies/codemod.yaml b/codemods/misc/remove-mui-dependencies/codemod.yaml new file mode 100644 index 0000000..2b258a9 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/remove-mui-dependencies' +version: '0.1.0' +description: 'MUI 4 to BUI: Remove unused @material-ui/* dependencies from package.json' +author: 'Paul Schultz' +license: 'Apache-2.0' +repository: 'https://github.com/backstage/codemods' +workflow: 'workflow.yaml' + +targets: + languages: ['json', 'tsx', 'ts'] + +keywords: ['backstage', 'migration', 'mui', 'bui', 'package.json', 'cleanup'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/remove-mui-dependencies/package.json b/codemods/misc/remove-mui-dependencies/package.json new file mode 100644 index 0000000..1a8ebef --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/remove-mui-dependencies", + "version": "0.1.0", + "description": "MUI 4 to BUI: Remove unused @material-ui/* dependencies from package.json", + "type": "module", + "scripts": { + "test": "yarn exec codemod jssg test -l tsx ./scripts/scan-mui-usage.ts ./tests && yarn exec codemod jssg test -l json --allow-fs ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + }, + "devDependencies": { + "@codemod.com/jssg-types": "1.6.2", + "codemod": "1.12.3" + } +} diff --git a/codemods/misc/remove-mui-dependencies/scripts/codemod.ts b/codemods/misc/remove-mui-dependencies/scripts/codemod.ts new file mode 100644 index 0000000..bef45ae --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/scripts/codemod.ts @@ -0,0 +1,237 @@ +import type { Codemod } from 'codemod:ast-grep' +import type JSON from 'codemod:ast-grep/langs/json' +import { useMetricAtom } from 'codemod:metrics' +// oxlint-disable-next-line unicorn/prefer-node-protocol -- fs/promises is the typed LLRT entrypoint +import { access, constants, readdir, readFile } from 'fs/promises' + +const migrationMetric = useMetricAtom('remove-mui-dependencies') +const muiUsageMetric = useMetricAtom('mui-import-usage') + +const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/lab', '@material-ui/styles'] as const + +type MuiPackage = (typeof MUI_PACKAGES)[number] + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']) + +const SOURCE_DIRS = ['src', 'dev'] as const + +const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-types', 'build', 'coverage', '.git', 'target']) + +interface PackageJson { + dependencies?: Record + devDependencies?: Record + [key: string]: unknown +} + +function normalizeSource(source: string): string { + return source.replaceAll('\r\n', '\n').replaceAll('\r', '\n') +} + +function normalizeFilepath(filepath: string): string { + let normalized = filepath.replaceAll('\\', '/') + if (normalized.startsWith('/?/')) { + normalized = normalized.slice(3) + } else if (/^\/[A-Za-z]:\//.test(normalized)) { + normalized = normalized.slice(1) + } + return normalized +} + +function dirname(filepath: string): string { + const normalized = normalizeFilepath(filepath) + const idx = normalized.lastIndexOf('/') + return idx === -1 ? '.' : normalized.slice(0, idx) +} + +function getPackageDir(root: { filename(): string; relativeFilename(): string }): string { + const relative = root.relativeFilename() + if (relative && relative !== 'anonymous') { + return dirname(normalizeFilepath(relative)) + } + return dirname(normalizeFilepath(root.filename())) +} + +function sortObjectKeys(obj: Record): Record { + const sorted: Record = {} + for (const key of Object.keys(obj).sort()) { + const value = obj[key] + if (value !== undefined) { + sorted[key] = value + } + } + return sorted +} + +function listMuiDependencies(pkg: PackageJson): MuiPackage[] { + const found: MuiPackage[] = [] + for (const name of MUI_PACKAGES) { + if (pkg.dependencies?.[name] !== undefined || pkg.devDependencies?.[name] !== undefined) { + found.push(name) + } + } + return found +} + +function getUsedPackagesFromMetrics(packageDir: string): Set { + const used = new Set() + for (const entry of muiUsageMetric.getEntries()) { + if (entry.cardinality.workspacePackage === packageDir && entry.cardinality.muiPackage) { + used.add(entry.cardinality.muiPackage as MuiPackage) + } + } + return used +} + +async function collectSourceFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + const fullPath = `${dir}/${entry.name}`.replaceAll('\\', '/') + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) { + continue + } + files.push(...(await collectSourceFiles(fullPath))) + continue + } + + if (!entry.isFile()) { + continue + } + + const dotIndex = entry.name.lastIndexOf('.') + if (dotIndex === -1) { + continue + } + + const ext = entry.name.slice(dotIndex) + if (SOURCE_EXTENSIONS.has(ext)) { + files.push(fullPath) + } + } + + return files +} + +async function directoryExists(dir: string): Promise { + try { + await access(dir, constants.F_OK) + return true + } catch { + return false + } +} + +async function scanSourceFilesForMuiUsage(dir: string, used: Set): Promise { + const files = await collectSourceFiles(dir) + + for (const file of files) { + const content = await readFile(file, 'utf8') + for (const pkg of MUI_PACKAGES) { + if (content.includes(pkg)) { + used.add(pkg) + } + } + } +} + +async function getUsedMuiPackagesViaFs(packageDir: string): Promise> { + const used = new Set() + + for (const sourceDir of SOURCE_DIRS) { + const dir = `${packageDir}/${sourceDir}`.replaceAll('\\', '/') + if (await directoryExists(dir)) { + await scanSourceFilesForMuiUsage(dir, used) + } + } + + return used +} + +async function resolveUsedMuiPackages(packageDir: string): Promise> { + const fromMetrics = getUsedPackagesFromMetrics(packageDir) + if (fromMetrics.size > 0) { + return fromMetrics + } + + return getUsedMuiPackagesViaFs(packageDir) +} + +function removeUnusedMuiPackages( + section: Record | undefined, + usedPackages: Set, +): { deps: Record | undefined; removed: MuiPackage[] } { + if (section === undefined) { + return { deps: undefined, removed: [] } + } + + const removed: MuiPackage[] = [] + const deps = { ...section } + + for (const name of MUI_PACKAGES) { + if (deps[name] !== undefined && !usedPackages.has(name)) { + delete deps[name] + removed.push(name) + } + } + + if (Object.keys(deps).length === 0) { + return { deps: undefined, removed } + } + + return { deps: sortObjectKeys(deps), removed } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const source = normalizeSource(rootNode.text()) + + let pkg: PackageJson + try { + pkg = globalThis.JSON.parse(source) as PackageJson + } catch { + return null + } + + if (listMuiDependencies(pkg).length === 0) { + return null + } + + const packageDir = getPackageDir(root) + const usedPackages = await resolveUsedMuiPackages(packageDir) + + const { deps: dependencies, removed: removedFromDependencies } = removeUnusedMuiPackages( + pkg.dependencies, + usedPackages, + ) + const { deps: devDependencies, removed: removedFromDevDependencies } = removeUnusedMuiPackages( + pkg.devDependencies, + usedPackages, + ) + + const removed = [...removedFromDependencies, ...removedFromDevDependencies] + if (removed.length === 0) { + migrationMetric.increment({ action: 'skipped-still-in-use' }) + return null + } + + for (const name of removed) { + migrationMetric.increment({ action: 'mui-dependency-removed', package: name }) + } + + pkg.dependencies = dependencies + pkg.devDependencies = devDependencies + + const indentMatch = source.match(/\n(\s+)"/) + const indent = indentMatch?.[1] ?? ' ' + const result = `${globalThis.JSON.stringify(pkg, null, indent)}\n` + + if (result === source) { + return null + } + + return result +} + +export default transform diff --git a/codemods/misc/remove-mui-dependencies/scripts/scan-mui-usage.ts b/codemods/misc/remove-mui-dependencies/scripts/scan-mui-usage.ts new file mode 100644 index 0000000..b40e148 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/scripts/scan-mui-usage.ts @@ -0,0 +1,66 @@ +import type { Codemod } from 'codemod:ast-grep' +import type TSX from 'codemod:ast-grep/langs/tsx' +import { useMetricAtom } from 'codemod:metrics' + +const muiUsageMetric = useMetricAtom('mui-import-usage') + +const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/lab', '@material-ui/styles'] as const + +type MuiPackage = (typeof MUI_PACKAGES)[number] + +function normalizeFilepath(filepath: string): string { + let normalized = filepath.replaceAll('\\', '/') + if (normalized.startsWith('/?/')) { + normalized = normalized.slice(3) + } else if (/^\/[A-Za-z]:\//.test(normalized)) { + normalized = normalized.slice(1) + } + return normalized +} + +function dirname(filepath: string): string { + const normalized = normalizeFilepath(filepath) + const idx = normalized.lastIndexOf('/') + return idx === -1 ? '.' : normalized.slice(0, idx) +} + +function getWorkspacePackage(root: { filename(): string; relativeFilename(): string }): string { + const relative = root.relativeFilename() + if (relative && relative !== 'anonymous') { + const normalized = normalizeFilepath(relative) + for (const marker of ['/src/', '/dev/']) { + const idx = normalized.indexOf(marker) + if (idx !== -1) { + return normalized.slice(0, idx) + } + } + return dirname(normalized) + } + return dirname(normalizeFilepath(root.filename())) +} + +function getMuiPackagesFromSource(source: string): Set { + const used = new Set() + for (const pkg of MUI_PACKAGES) { + if (source.includes(pkg)) { + used.add(pkg) + } + } + return used +} + +const transform: Codemod = (root) => { + const used = getMuiPackagesFromSource(root.root().text()) + if (used.size === 0) { + return Promise.resolve(null) + } + + const workspacePackage = getWorkspacePackage(root) + for (const pkg of used) { + muiUsageMetric.increment({ workspacePackage, muiPackage: pkg }) + } + + return Promise.resolve(null) +} + +export default transform diff --git a/codemods/misc/remove-mui-dependencies/tests/no-mui-deps/expected.json b/codemods/misc/remove-mui-dependencies/tests/no-mui-deps/expected.json new file mode 100644 index 0000000..c0a8218 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/no-mui-deps/expected.json @@ -0,0 +1,7 @@ +{ + "name": "my-plugin", + "dependencies": { + "@backstage/ui": "^0.16.0", + "react": "^18.0.0" + } +} diff --git a/codemods/misc/remove-mui-dependencies/tests/no-mui-deps/input.json b/codemods/misc/remove-mui-dependencies/tests/no-mui-deps/input.json new file mode 100644 index 0000000..c0a8218 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/no-mui-deps/input.json @@ -0,0 +1,7 @@ +{ + "name": "my-plugin", + "dependencies": { + "@backstage/ui": "^0.16.0", + "react": "^18.0.0" + } +} diff --git a/codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/expected.json b/codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/expected.json new file mode 100644 index 0000000..c0a8218 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/expected.json @@ -0,0 +1,7 @@ +{ + "name": "my-plugin", + "dependencies": { + "@backstage/ui": "^0.16.0", + "react": "^18.0.0" + } +} diff --git a/codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/input.json b/codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/input.json new file mode 100644 index 0000000..fb625e3 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/input.json @@ -0,0 +1,10 @@ +{ + "name": "my-plugin", + "dependencies": { + "@backstage/ui": "^0.16.0", + "@material-ui/core": "^4.12.4", + "@material-ui/icons": "^4.11.3", + "@material-ui/lab": "^4.0.0-alpha.61", + "react": "^18.0.0" + } +} diff --git a/codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/metrics.json b/codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/metrics.json new file mode 100644 index 0000000..6041939 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/removes-all-mui-deps/metrics.json @@ -0,0 +1,25 @@ +{ + "remove-mui-dependencies": [ + { + "cardinality": { + "action": "mui-dependency-removed", + "package": "@material-ui/core" + }, + "count": 1 + }, + { + "cardinality": { + "action": "mui-dependency-removed", + "package": "@material-ui/icons" + }, + "count": 1 + }, + { + "cardinality": { + "action": "mui-dependency-removed", + "package": "@material-ui/lab" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/expected.json b/codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/expected.json new file mode 100644 index 0000000..2ed49b5 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/expected.json @@ -0,0 +1,6 @@ +{ + "name": "my-plugin", + "devDependencies": { + "@types/react": "^18.0.0" + } +} diff --git a/codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/input.json b/codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/input.json new file mode 100644 index 0000000..8e5e47e --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/input.json @@ -0,0 +1,7 @@ +{ + "name": "my-plugin", + "devDependencies": { + "@material-ui/styles": "^4.11.5", + "@types/react": "^18.0.0" + } +} diff --git a/codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/metrics.json b/codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/metrics.json new file mode 100644 index 0000000..e3b1e8e --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/removes-from-dev-dependencies/metrics.json @@ -0,0 +1,11 @@ +{ + "remove-mui-dependencies": [ + { + "cardinality": { + "action": "mui-dependency-removed", + "package": "@material-ui/styles" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/expected.tsx b/codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/expected.tsx new file mode 100644 index 0000000..1e82e7e --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/expected.tsx @@ -0,0 +1,3 @@ +import Button from '@material-ui/core/Button'; + +export const MyButton = () => ; diff --git a/codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/input.tsx b/codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/input.tsx new file mode 100644 index 0000000..1e82e7e --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/input.tsx @@ -0,0 +1,3 @@ +import Button from '@material-ui/core/Button'; + +export const MyButton = () => ; diff --git a/codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/metrics.json b/codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/metrics.json new file mode 100644 index 0000000..03e5696 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tests/scan-detects-core-import/metrics.json @@ -0,0 +1,11 @@ +{ + "mui-import-usage": [ + { + "cardinality": { + "muiPackage": "@material-ui/core", + "workspacePackage": "." + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/remove-mui-dependencies/tsconfig.json b/codemods/misc/remove-mui-dependencies/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["@codemod.com/jssg-types"], + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true + }, + "exclude": ["tests", "node_modules"] +} diff --git a/codemods/misc/remove-mui-dependencies/workflow.yaml b/codemods/misc/remove-mui-dependencies/workflow.yaml new file mode 100644 index 0000000..5921ab2 --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/workflow.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod/codemod/refs/heads/main/schemas/workflow.json + +version: '1' + +nodes: + - id: apply-transforms + name: 'Remove unused MUI dependencies from package.json' + type: automatic + steps: + - name: 'Scan source files for remaining @material-ui/* imports' + js-ast-grep: + js_file: scripts/scan-mui-usage.ts + language: 'tsx' + semantic_analysis: file + include: + - '**/*.ts' + - '**/*.tsx' + exclude: + - '**/node_modules/**' + - name: 'Remove @material-ui/* packages when no longer imported in the package' + js-ast-grep: + js_file: scripts/codemod.ts + language: 'json' + include: + - '**/package.json' + exclude: + - '**/node_modules/**' diff --git a/yarn.lock b/yarn.lock index 4b62a40..246ce24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -361,6 +361,15 @@ __metadata: languageName: unknown linkType: soft +"@backstage/remove-mui-dependencies@workspace:codemods/misc/remove-mui-dependencies": + version: 0.0.0-use.local + resolution: "@backstage/remove-mui-dependencies@workspace:codemods/misc/remove-mui-dependencies" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + "@backstage/remove-stitching-strategy-mode@workspace:codemods/v1.52.0/remove-stitching-strategy-mode": version: 0.0.0-use.local resolution: "@backstage/remove-stitching-strategy-mode@workspace:codemods/v1.52.0/remove-stitching-strategy-mode" From 0a0fe4c4fe52dc9cab9c136e0f3ab5b0e9977112 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 10:32:23 -0500 Subject: [PATCH 06/12] fix: simplify remove-mui-dependencies to metrics-only Drop fs/promises scanning and the oxlint disable workaround. The JSON step reads usage recorded by the scan step in the same workflow run. Co-authored-by: Cursor --- .../misc/remove-mui-dependencies/package.json | 2 +- .../scripts/codemod.ts | 98 ++----------------- 2 files changed, 8 insertions(+), 92 deletions(-) diff --git a/codemods/misc/remove-mui-dependencies/package.json b/codemods/misc/remove-mui-dependencies/package.json index 1a8ebef..5549e5e 100644 --- a/codemods/misc/remove-mui-dependencies/package.json +++ b/codemods/misc/remove-mui-dependencies/package.json @@ -4,7 +4,7 @@ "description": "MUI 4 to BUI: Remove unused @material-ui/* dependencies from package.json", "type": "module", "scripts": { - "test": "yarn exec codemod jssg test -l tsx ./scripts/scan-mui-usage.ts ./tests && yarn exec codemod jssg test -l json --allow-fs ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + "test": "yarn exec codemod jssg test -l tsx ./scripts/scan-mui-usage.ts ./tests && yarn exec codemod jssg test -l json ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" }, "devDependencies": { "@codemod.com/jssg-types": "1.6.2", diff --git a/codemods/misc/remove-mui-dependencies/scripts/codemod.ts b/codemods/misc/remove-mui-dependencies/scripts/codemod.ts index bef45ae..2c0643b 100644 --- a/codemods/misc/remove-mui-dependencies/scripts/codemod.ts +++ b/codemods/misc/remove-mui-dependencies/scripts/codemod.ts @@ -1,8 +1,6 @@ import type { Codemod } from 'codemod:ast-grep' import type JSON from 'codemod:ast-grep/langs/json' import { useMetricAtom } from 'codemod:metrics' -// oxlint-disable-next-line unicorn/prefer-node-protocol -- fs/promises is the typed LLRT entrypoint -import { access, constants, readdir, readFile } from 'fs/promises' const migrationMetric = useMetricAtom('remove-mui-dependencies') const muiUsageMetric = useMetricAtom('mui-import-usage') @@ -11,12 +9,6 @@ const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/l type MuiPackage = (typeof MUI_PACKAGES)[number] -const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']) - -const SOURCE_DIRS = ['src', 'dev'] as const - -const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-types', 'build', 'coverage', '.git', 'target']) - interface PackageJson { dependencies?: Record devDependencies?: Record @@ -82,82 +74,6 @@ function getUsedPackagesFromMetrics(packageDir: string): Set { return used } -async function collectSourceFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - const files: string[] = [] - - for (const entry of entries) { - const fullPath = `${dir}/${entry.name}`.replaceAll('\\', '/') - if (entry.isDirectory()) { - if (SKIP_DIRS.has(entry.name)) { - continue - } - files.push(...(await collectSourceFiles(fullPath))) - continue - } - - if (!entry.isFile()) { - continue - } - - const dotIndex = entry.name.lastIndexOf('.') - if (dotIndex === -1) { - continue - } - - const ext = entry.name.slice(dotIndex) - if (SOURCE_EXTENSIONS.has(ext)) { - files.push(fullPath) - } - } - - return files -} - -async function directoryExists(dir: string): Promise { - try { - await access(dir, constants.F_OK) - return true - } catch { - return false - } -} - -async function scanSourceFilesForMuiUsage(dir: string, used: Set): Promise { - const files = await collectSourceFiles(dir) - - for (const file of files) { - const content = await readFile(file, 'utf8') - for (const pkg of MUI_PACKAGES) { - if (content.includes(pkg)) { - used.add(pkg) - } - } - } -} - -async function getUsedMuiPackagesViaFs(packageDir: string): Promise> { - const used = new Set() - - for (const sourceDir of SOURCE_DIRS) { - const dir = `${packageDir}/${sourceDir}`.replaceAll('\\', '/') - if (await directoryExists(dir)) { - await scanSourceFilesForMuiUsage(dir, used) - } - } - - return used -} - -async function resolveUsedMuiPackages(packageDir: string): Promise> { - const fromMetrics = getUsedPackagesFromMetrics(packageDir) - if (fromMetrics.size > 0) { - return fromMetrics - } - - return getUsedMuiPackagesViaFs(packageDir) -} - function removeUnusedMuiPackages( section: Record | undefined, usedPackages: Set, @@ -183,7 +99,7 @@ function removeUnusedMuiPackages( return { deps: sortObjectKeys(deps), removed } } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const source = normalizeSource(rootNode.text()) @@ -191,15 +107,15 @@ const transform: Codemod = async (root) => { try { pkg = globalThis.JSON.parse(source) as PackageJson } catch { - return null + return Promise.resolve(null) } if (listMuiDependencies(pkg).length === 0) { - return null + return Promise.resolve(null) } const packageDir = getPackageDir(root) - const usedPackages = await resolveUsedMuiPackages(packageDir) + const usedPackages = getUsedPackagesFromMetrics(packageDir) const { deps: dependencies, removed: removedFromDependencies } = removeUnusedMuiPackages( pkg.dependencies, @@ -213,7 +129,7 @@ const transform: Codemod = async (root) => { const removed = [...removedFromDependencies, ...removedFromDevDependencies] if (removed.length === 0) { migrationMetric.increment({ action: 'skipped-still-in-use' }) - return null + return Promise.resolve(null) } for (const name of removed) { @@ -228,10 +144,10 @@ const transform: Codemod = async (root) => { const result = `${globalThis.JSON.stringify(pkg, null, indent)}\n` if (result === source) { - return null + return Promise.resolve(null) } - return result + return Promise.resolve(result) } export default transform From ed3a04c2d6d4f5302c9784520ade19141ee0686a Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 10:36:40 -0500 Subject: [PATCH 07/12] fix: restore fs fallback in remove-mui-dependencies Revert the metrics-only refactor. Keep metrics-first resolution with fs/promises fallback for src/ and dev/ scanning. Fix CI by moving the oxlint disable to an end-of-line comment so oxfmt accepts the import. Co-authored-by: Cursor --- .../misc/remove-mui-dependencies/package.json | 2 +- .../scripts/codemod.ts | 98 +++++++++++++++++-- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/codemods/misc/remove-mui-dependencies/package.json b/codemods/misc/remove-mui-dependencies/package.json index 5549e5e..1a8ebef 100644 --- a/codemods/misc/remove-mui-dependencies/package.json +++ b/codemods/misc/remove-mui-dependencies/package.json @@ -4,7 +4,7 @@ "description": "MUI 4 to BUI: Remove unused @material-ui/* dependencies from package.json", "type": "module", "scripts": { - "test": "yarn exec codemod jssg test -l tsx ./scripts/scan-mui-usage.ts ./tests && yarn exec codemod jssg test -l json ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + "test": "yarn exec codemod jssg test -l tsx ./scripts/scan-mui-usage.ts ./tests && yarn exec codemod jssg test -l json --allow-fs ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" }, "devDependencies": { "@codemod.com/jssg-types": "1.6.2", diff --git a/codemods/misc/remove-mui-dependencies/scripts/codemod.ts b/codemods/misc/remove-mui-dependencies/scripts/codemod.ts index 2c0643b..a45910e 100644 --- a/codemods/misc/remove-mui-dependencies/scripts/codemod.ts +++ b/codemods/misc/remove-mui-dependencies/scripts/codemod.ts @@ -1,3 +1,5 @@ +import { access, constants, readdir, readFile } from 'fs/promises' // oxlint-disable-line unicorn/prefer-node-protocol -- LLRT typed entrypoint + import type { Codemod } from 'codemod:ast-grep' import type JSON from 'codemod:ast-grep/langs/json' import { useMetricAtom } from 'codemod:metrics' @@ -9,6 +11,12 @@ const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/l type MuiPackage = (typeof MUI_PACKAGES)[number] +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']) + +const SOURCE_DIRS = ['src', 'dev'] as const + +const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-types', 'build', 'coverage', '.git', 'target']) + interface PackageJson { dependencies?: Record devDependencies?: Record @@ -74,6 +82,82 @@ function getUsedPackagesFromMetrics(packageDir: string): Set { return used } +async function collectSourceFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + const fullPath = `${dir}/${entry.name}`.replaceAll('\\', '/') + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) { + continue + } + files.push(...(await collectSourceFiles(fullPath))) + continue + } + + if (!entry.isFile()) { + continue + } + + const dotIndex = entry.name.lastIndexOf('.') + if (dotIndex === -1) { + continue + } + + const ext = entry.name.slice(dotIndex) + if (SOURCE_EXTENSIONS.has(ext)) { + files.push(fullPath) + } + } + + return files +} + +async function directoryExists(dir: string): Promise { + try { + await access(dir, constants.F_OK) + return true + } catch { + return false + } +} + +async function scanSourceFilesForMuiUsage(dir: string, used: Set): Promise { + const files = await collectSourceFiles(dir) + + for (const file of files) { + const content = await readFile(file, 'utf8') + for (const pkg of MUI_PACKAGES) { + if (content.includes(pkg)) { + used.add(pkg) + } + } + } +} + +async function getUsedMuiPackagesViaFs(packageDir: string): Promise> { + const used = new Set() + + for (const sourceDir of SOURCE_DIRS) { + const dir = `${packageDir}/${sourceDir}`.replaceAll('\\', '/') + if (await directoryExists(dir)) { + await scanSourceFilesForMuiUsage(dir, used) + } + } + + return used +} + +async function resolveUsedMuiPackages(packageDir: string): Promise> { + const fromMetrics = getUsedPackagesFromMetrics(packageDir) + if (fromMetrics.size > 0) { + return fromMetrics + } + + return getUsedMuiPackagesViaFs(packageDir) +} + function removeUnusedMuiPackages( section: Record | undefined, usedPackages: Set, @@ -99,7 +183,7 @@ function removeUnusedMuiPackages( return { deps: sortObjectKeys(deps), removed } } -const transform: Codemod = (root) => { +const transform: Codemod = async (root) => { const rootNode = root.root() const source = normalizeSource(rootNode.text()) @@ -107,15 +191,15 @@ const transform: Codemod = (root) => { try { pkg = globalThis.JSON.parse(source) as PackageJson } catch { - return Promise.resolve(null) + return null } if (listMuiDependencies(pkg).length === 0) { - return Promise.resolve(null) + return null } const packageDir = getPackageDir(root) - const usedPackages = getUsedPackagesFromMetrics(packageDir) + const usedPackages = await resolveUsedMuiPackages(packageDir) const { deps: dependencies, removed: removedFromDependencies } = removeUnusedMuiPackages( pkg.dependencies, @@ -129,7 +213,7 @@ const transform: Codemod = (root) => { const removed = [...removedFromDependencies, ...removedFromDevDependencies] if (removed.length === 0) { migrationMetric.increment({ action: 'skipped-still-in-use' }) - return Promise.resolve(null) + return null } for (const name of removed) { @@ -144,10 +228,10 @@ const transform: Codemod = (root) => { const result = `${globalThis.JSON.stringify(pkg, null, indent)}\n` if (result === source) { - return Promise.resolve(null) + return null } - return Promise.resolve(result) + return result } export default transform From 86e0bd9f63e7d15124830f2b9615297e8dcb19ab Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 10:40:25 -0500 Subject: [PATCH 08/12] refactor: align remove-mui-dependencies with package-json codemod pattern Split into codemod.ts (TSX scan) and package-json-codemod.ts (pure JSON transform via workflow metrics). Drop fs/promises fallback and --allow-fs tests to match bootstrap and add-jest-peer-dependency conventions. Co-authored-by: Cursor --- .../misc/remove-mui-dependencies/CHANGELOG.md | 2 +- .../misc/remove-mui-dependencies/package.json | 2 +- .../scripts/codemod.ts | 213 ++---------------- .../scripts/package-json-codemod.ts | 149 ++++++++++++ .../scripts/scan-mui-usage.ts | 66 ------ .../remove-mui-dependencies/workflow.yaml | 4 +- 6 files changed, 174 insertions(+), 262 deletions(-) create mode 100644 codemods/misc/remove-mui-dependencies/scripts/package-json-codemod.ts delete mode 100644 codemods/misc/remove-mui-dependencies/scripts/scan-mui-usage.ts diff --git a/codemods/misc/remove-mui-dependencies/CHANGELOG.md b/codemods/misc/remove-mui-dependencies/CHANGELOG.md index c93e20e..f167bb6 100644 --- a/codemods/misc/remove-mui-dependencies/CHANGELOG.md +++ b/codemods/misc/remove-mui-dependencies/CHANGELOG.md @@ -4,4 +4,4 @@ ### Minor Changes -- Initial release: remove unused `@material-ui/*` dependencies from `package.json` after source files in the same package no longer import them. +- Initial release: remove unused `@material-ui/*` dependencies from `package.json` after source files in the same package no longer import them. Uses a TSX scan step plus a JSON transform (`package-json-codemod.ts`), matching the bootstrap codemod layout. diff --git a/codemods/misc/remove-mui-dependencies/package.json b/codemods/misc/remove-mui-dependencies/package.json index 1a8ebef..e7852bf 100644 --- a/codemods/misc/remove-mui-dependencies/package.json +++ b/codemods/misc/remove-mui-dependencies/package.json @@ -4,7 +4,7 @@ "description": "MUI 4 to BUI: Remove unused @material-ui/* dependencies from package.json", "type": "module", "scripts": { - "test": "yarn exec codemod jssg test -l tsx ./scripts/scan-mui-usage.ts ./tests && yarn exec codemod jssg test -l json --allow-fs ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" + "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod jssg test -l json ./scripts/package-json-codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" }, "devDependencies": { "@codemod.com/jssg-types": "1.6.2", diff --git a/codemods/misc/remove-mui-dependencies/scripts/codemod.ts b/codemods/misc/remove-mui-dependencies/scripts/codemod.ts index a45910e..b40e148 100644 --- a/codemods/misc/remove-mui-dependencies/scripts/codemod.ts +++ b/codemods/misc/remove-mui-dependencies/scripts/codemod.ts @@ -1,32 +1,13 @@ -import { access, constants, readdir, readFile } from 'fs/promises' // oxlint-disable-line unicorn/prefer-node-protocol -- LLRT typed entrypoint - import type { Codemod } from 'codemod:ast-grep' -import type JSON from 'codemod:ast-grep/langs/json' +import type TSX from 'codemod:ast-grep/langs/tsx' import { useMetricAtom } from 'codemod:metrics' -const migrationMetric = useMetricAtom('remove-mui-dependencies') const muiUsageMetric = useMetricAtom('mui-import-usage') const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/lab', '@material-ui/styles'] as const type MuiPackage = (typeof MUI_PACKAGES)[number] -const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']) - -const SOURCE_DIRS = ['src', 'dev'] as const - -const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-types', 'build', 'coverage', '.git', 'target']) - -interface PackageJson { - dependencies?: Record - devDependencies?: Record - [key: string]: unknown -} - -function normalizeSource(source: string): string { - return source.replaceAll('\r\n', '\n').replaceAll('\r', '\n') -} - function normalizeFilepath(filepath: string): string { let normalized = filepath.replaceAll('\\', '/') if (normalized.startsWith('/?/')) { @@ -43,195 +24,43 @@ function dirname(filepath: string): string { return idx === -1 ? '.' : normalized.slice(0, idx) } -function getPackageDir(root: { filename(): string; relativeFilename(): string }): string { +function getWorkspacePackage(root: { filename(): string; relativeFilename(): string }): string { const relative = root.relativeFilename() if (relative && relative !== 'anonymous') { - return dirname(normalizeFilepath(relative)) - } - return dirname(normalizeFilepath(root.filename())) -} - -function sortObjectKeys(obj: Record): Record { - const sorted: Record = {} - for (const key of Object.keys(obj).sort()) { - const value = obj[key] - if (value !== undefined) { - sorted[key] = value - } - } - return sorted -} - -function listMuiDependencies(pkg: PackageJson): MuiPackage[] { - const found: MuiPackage[] = [] - for (const name of MUI_PACKAGES) { - if (pkg.dependencies?.[name] !== undefined || pkg.devDependencies?.[name] !== undefined) { - found.push(name) - } - } - return found -} - -function getUsedPackagesFromMetrics(packageDir: string): Set { - const used = new Set() - for (const entry of muiUsageMetric.getEntries()) { - if (entry.cardinality.workspacePackage === packageDir && entry.cardinality.muiPackage) { - used.add(entry.cardinality.muiPackage as MuiPackage) - } - } - return used -} - -async function collectSourceFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - const files: string[] = [] - - for (const entry of entries) { - const fullPath = `${dir}/${entry.name}`.replaceAll('\\', '/') - if (entry.isDirectory()) { - if (SKIP_DIRS.has(entry.name)) { - continue - } - files.push(...(await collectSourceFiles(fullPath))) - continue - } - - if (!entry.isFile()) { - continue - } - - const dotIndex = entry.name.lastIndexOf('.') - if (dotIndex === -1) { - continue - } - - const ext = entry.name.slice(dotIndex) - if (SOURCE_EXTENSIONS.has(ext)) { - files.push(fullPath) - } - } - - return files -} - -async function directoryExists(dir: string): Promise { - try { - await access(dir, constants.F_OK) - return true - } catch { - return false - } -} - -async function scanSourceFilesForMuiUsage(dir: string, used: Set): Promise { - const files = await collectSourceFiles(dir) - - for (const file of files) { - const content = await readFile(file, 'utf8') - for (const pkg of MUI_PACKAGES) { - if (content.includes(pkg)) { - used.add(pkg) + const normalized = normalizeFilepath(relative) + for (const marker of ['/src/', '/dev/']) { + const idx = normalized.indexOf(marker) + if (idx !== -1) { + return normalized.slice(0, idx) } } + return dirname(normalized) } + return dirname(normalizeFilepath(root.filename())) } -async function getUsedMuiPackagesViaFs(packageDir: string): Promise> { +function getMuiPackagesFromSource(source: string): Set { const used = new Set() - - for (const sourceDir of SOURCE_DIRS) { - const dir = `${packageDir}/${sourceDir}`.replaceAll('\\', '/') - if (await directoryExists(dir)) { - await scanSourceFilesForMuiUsage(dir, used) + for (const pkg of MUI_PACKAGES) { + if (source.includes(pkg)) { + used.add(pkg) } } - return used } -async function resolveUsedMuiPackages(packageDir: string): Promise> { - const fromMetrics = getUsedPackagesFromMetrics(packageDir) - if (fromMetrics.size > 0) { - return fromMetrics - } - - return getUsedMuiPackagesViaFs(packageDir) -} - -function removeUnusedMuiPackages( - section: Record | undefined, - usedPackages: Set, -): { deps: Record | undefined; removed: MuiPackage[] } { - if (section === undefined) { - return { deps: undefined, removed: [] } - } - - const removed: MuiPackage[] = [] - const deps = { ...section } - - for (const name of MUI_PACKAGES) { - if (deps[name] !== undefined && !usedPackages.has(name)) { - delete deps[name] - removed.push(name) - } +const transform: Codemod = (root) => { + const used = getMuiPackagesFromSource(root.root().text()) + if (used.size === 0) { + return Promise.resolve(null) } - if (Object.keys(deps).length === 0) { - return { deps: undefined, removed } - } - - return { deps: sortObjectKeys(deps), removed } -} - -const transform: Codemod = async (root) => { - const rootNode = root.root() - const source = normalizeSource(rootNode.text()) - - let pkg: PackageJson - try { - pkg = globalThis.JSON.parse(source) as PackageJson - } catch { - return null - } - - if (listMuiDependencies(pkg).length === 0) { - return null - } - - const packageDir = getPackageDir(root) - const usedPackages = await resolveUsedMuiPackages(packageDir) - - const { deps: dependencies, removed: removedFromDependencies } = removeUnusedMuiPackages( - pkg.dependencies, - usedPackages, - ) - const { deps: devDependencies, removed: removedFromDevDependencies } = removeUnusedMuiPackages( - pkg.devDependencies, - usedPackages, - ) - - const removed = [...removedFromDependencies, ...removedFromDevDependencies] - if (removed.length === 0) { - migrationMetric.increment({ action: 'skipped-still-in-use' }) - return null - } - - for (const name of removed) { - migrationMetric.increment({ action: 'mui-dependency-removed', package: name }) - } - - pkg.dependencies = dependencies - pkg.devDependencies = devDependencies - - const indentMatch = source.match(/\n(\s+)"/) - const indent = indentMatch?.[1] ?? ' ' - const result = `${globalThis.JSON.stringify(pkg, null, indent)}\n` - - if (result === source) { - return null + const workspacePackage = getWorkspacePackage(root) + for (const pkg of used) { + muiUsageMetric.increment({ workspacePackage, muiPackage: pkg }) } - return result + return Promise.resolve(null) } export default transform diff --git a/codemods/misc/remove-mui-dependencies/scripts/package-json-codemod.ts b/codemods/misc/remove-mui-dependencies/scripts/package-json-codemod.ts new file mode 100644 index 0000000..a172b5a --- /dev/null +++ b/codemods/misc/remove-mui-dependencies/scripts/package-json-codemod.ts @@ -0,0 +1,149 @@ +import type { Codemod } from 'codemod:ast-grep' +import type JSON from 'codemod:ast-grep/langs/json' +import { useMetricAtom } from 'codemod:metrics' + +const migrationMetric = useMetricAtom('remove-mui-dependencies') +const muiUsageMetric = useMetricAtom('mui-import-usage') + +const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/lab', '@material-ui/styles'] as const + +type MuiPackage = (typeof MUI_PACKAGES)[number] + +interface PackageJson { + dependencies?: Record + devDependencies?: Record + [key: string]: unknown +} + +function normalizeSource(source: string): string { + return source.replaceAll('\r\n', '\n').replaceAll('\r', '\n') +} + +function normalizeFilepath(filepath: string): string { + let normalized = filepath.replaceAll('\\', '/') + if (normalized.startsWith('/?/')) { + normalized = normalized.slice(3) + } else if (/^\/[A-Za-z]:\//.test(normalized)) { + normalized = normalized.slice(1) + } + return normalized +} + +function dirname(filepath: string): string { + const normalized = normalizeFilepath(filepath) + const idx = normalized.lastIndexOf('/') + return idx === -1 ? '.' : normalized.slice(0, idx) +} + +function getPackageDir(root: { filename(): string; relativeFilename(): string }): string { + const relative = root.relativeFilename() + if (relative && relative !== 'anonymous') { + return dirname(normalizeFilepath(relative)) + } + return dirname(normalizeFilepath(root.filename())) +} + +function sortObjectKeys(obj: Record): Record { + const sorted: Record = {} + for (const key of Object.keys(obj).sort()) { + const value = obj[key] + if (value !== undefined) { + sorted[key] = value + } + } + return sorted +} + +function hasMuiDependency(pkg: PackageJson): boolean { + return MUI_PACKAGES.some( + (name) => pkg.dependencies?.[name] !== undefined || pkg.devDependencies?.[name] !== undefined, + ) +} + +function getUsedPackagesFromMetrics(packageDir: string): Set { + const used = new Set() + for (const entry of muiUsageMetric.getEntries()) { + if (entry.cardinality.workspacePackage === packageDir && entry.cardinality.muiPackage) { + used.add(entry.cardinality.muiPackage as MuiPackage) + } + } + return used +} + +function removeUnusedMuiPackages( + section: Record | undefined, + usedPackages: Set, +): { deps: Record | undefined; removed: MuiPackage[] } { + if (section === undefined) { + return { deps: undefined, removed: [] } + } + + const removed: MuiPackage[] = [] + const deps = { ...section } + + for (const name of MUI_PACKAGES) { + if (deps[name] !== undefined && !usedPackages.has(name)) { + delete deps[name] + removed.push(name) + } + } + + if (Object.keys(deps).length === 0) { + return { deps: undefined, removed } + } + + return { deps: sortObjectKeys(deps), removed } +} + +const transform: Codemod = (root) => { + const rootNode = root.root() + const source = normalizeSource(rootNode.text()) + + let pkg: PackageJson + try { + pkg = globalThis.JSON.parse(source) as PackageJson + } catch { + return Promise.resolve(null) + } + + if (!hasMuiDependency(pkg)) { + return Promise.resolve(null) + } + + const packageDir = getPackageDir(root) + const usedPackages = getUsedPackagesFromMetrics(packageDir) + + const { deps: dependencies, removed: removedFromDependencies } = removeUnusedMuiPackages( + pkg.dependencies, + usedPackages, + ) + const { deps: devDependencies, removed: removedFromDevDependencies } = removeUnusedMuiPackages( + pkg.devDependencies, + usedPackages, + ) + + const removed = [...removedFromDependencies, ...removedFromDevDependencies] + if (removed.length === 0) { + migrationMetric.increment({ action: 'skipped-still-in-use' }) + return Promise.resolve(null) + } + + for (const name of removed) { + migrationMetric.increment({ action: 'mui-dependency-removed', package: name }) + } + + pkg.dependencies = dependencies + pkg.devDependencies = devDependencies + + const indentMatch = source.match(/\n(\s+)"/) + const indent = indentMatch?.[1] ?? ' ' + const result = `${globalThis.JSON.stringify(pkg, null, indent)}\n` + + if (result === source) { + return Promise.resolve(null) + } + + return Promise.resolve(result) +} + +export default transform diff --git a/codemods/misc/remove-mui-dependencies/scripts/scan-mui-usage.ts b/codemods/misc/remove-mui-dependencies/scripts/scan-mui-usage.ts deleted file mode 100644 index b40e148..0000000 --- a/codemods/misc/remove-mui-dependencies/scripts/scan-mui-usage.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Codemod } from 'codemod:ast-grep' -import type TSX from 'codemod:ast-grep/langs/tsx' -import { useMetricAtom } from 'codemod:metrics' - -const muiUsageMetric = useMetricAtom('mui-import-usage') - -const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/lab', '@material-ui/styles'] as const - -type MuiPackage = (typeof MUI_PACKAGES)[number] - -function normalizeFilepath(filepath: string): string { - let normalized = filepath.replaceAll('\\', '/') - if (normalized.startsWith('/?/')) { - normalized = normalized.slice(3) - } else if (/^\/[A-Za-z]:\//.test(normalized)) { - normalized = normalized.slice(1) - } - return normalized -} - -function dirname(filepath: string): string { - const normalized = normalizeFilepath(filepath) - const idx = normalized.lastIndexOf('/') - return idx === -1 ? '.' : normalized.slice(0, idx) -} - -function getWorkspacePackage(root: { filename(): string; relativeFilename(): string }): string { - const relative = root.relativeFilename() - if (relative && relative !== 'anonymous') { - const normalized = normalizeFilepath(relative) - for (const marker of ['/src/', '/dev/']) { - const idx = normalized.indexOf(marker) - if (idx !== -1) { - return normalized.slice(0, idx) - } - } - return dirname(normalized) - } - return dirname(normalizeFilepath(root.filename())) -} - -function getMuiPackagesFromSource(source: string): Set { - const used = new Set() - for (const pkg of MUI_PACKAGES) { - if (source.includes(pkg)) { - used.add(pkg) - } - } - return used -} - -const transform: Codemod = (root) => { - const used = getMuiPackagesFromSource(root.root().text()) - if (used.size === 0) { - return Promise.resolve(null) - } - - const workspacePackage = getWorkspacePackage(root) - for (const pkg of used) { - muiUsageMetric.increment({ workspacePackage, muiPackage: pkg }) - } - - return Promise.resolve(null) -} - -export default transform diff --git a/codemods/misc/remove-mui-dependencies/workflow.yaml b/codemods/misc/remove-mui-dependencies/workflow.yaml index 5921ab2..05c2d01 100644 --- a/codemods/misc/remove-mui-dependencies/workflow.yaml +++ b/codemods/misc/remove-mui-dependencies/workflow.yaml @@ -9,7 +9,7 @@ nodes: steps: - name: 'Scan source files for remaining @material-ui/* imports' js-ast-grep: - js_file: scripts/scan-mui-usage.ts + js_file: scripts/codemod.ts language: 'tsx' semantic_analysis: file include: @@ -19,7 +19,7 @@ nodes: - '**/node_modules/**' - name: 'Remove @material-ui/* packages when no longer imported in the package' js-ast-grep: - js_file: scripts/codemod.ts + js_file: scripts/package-json-codemod.ts language: 'json' include: - '**/package.json' From 75101b368c7ed51150b5acd6b7e960223e318681 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 10:47:57 -0500 Subject: [PATCH 09/12] fix: address Copilot review feedback on foundation codemods Co-authored-by: Cursor --- .../scripts/package-json-codemod.ts | 14 ++-- .../workflow.yaml | 11 +++ .../scripts/codemod.ts | 5 +- .../scripts/codemod.ts | 75 ++++++++++++++----- .../tests/grid-simple/expected.tsx | 6 +- .../scripts/codemod.ts | 21 +++++- 6 files changed, 102 insertions(+), 30 deletions(-) diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts index 2980557..0c75b7b 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts @@ -9,9 +9,8 @@ const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/l const BUI_PACKAGE = '@backstage/ui' const REMIX_PACKAGE = '@remixicon/react' -/** Match semver caret ranges used by other Backstage codemods (e.g. add-jest-peer-dependency). */ -const BUI_VERSION = '^0.16.0' -const REMIX_VERSION = '^4.9.0' +const DEFAULT_BUI_VERSION = '^0.16.0' +const DEFAULT_REMIX_VERSION = '^4.9.0' interface PackageJson { dependencies?: Record @@ -55,10 +54,13 @@ function dependencySectionForMui(pkg: PackageJson): 'dependencies' | 'devDepende return 'devDependencies' } -const transform: Codemod = (root) => { +const transform: Codemod = async (root, options) => { const rootNode = root.root() const source = normalizeSource(rootNode.text()) + const buiVersion = options.params.buiVersion ?? DEFAULT_BUI_VERSION + const remixVersion = options.params.remixVersion ?? DEFAULT_REMIX_VERSION + let pkg: PackageJson try { pkg = globalThis.JSON.parse(source) as PackageJson @@ -75,13 +77,13 @@ const transform: Codemod = (root) => { let changed = false if (existingDeps[BUI_PACKAGE] === undefined) { - existingDeps[BUI_PACKAGE] = BUI_VERSION + existingDeps[BUI_PACKAGE] = buiVersion changed = true migrationMetric.increment({ action: 'bui-dependency-added' }) } if (hasMuiIcons(pkg) && existingDeps[REMIX_PACKAGE] === undefined) { - existingDeps[REMIX_PACKAGE] = REMIX_VERSION + existingDeps[REMIX_PACKAGE] = remixVersion changed = true migrationMetric.increment({ action: 'remix-dependency-added' }) } diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml b/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml index c3cca24..5a3a732 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml @@ -2,6 +2,17 @@ version: '1' +params: + schema: + buiVersion: + type: string + default: '^0.16.0' + description: 'Semver range for @backstage/ui (defaults match the MUI foundation migration recipe)' + remixVersion: + type: string + default: '^4.9.0' + description: 'Semver range for @remixicon/react when @material-ui/icons is present' + nodes: - id: apply-transforms name: 'Apply AST Transformations' diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts index 548b4a6..11fef2c 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts @@ -327,7 +327,10 @@ function transformIconJsx(rootNode: SgNode, iconLocalNames: Set, ed newProps.push(`size={${sizeNum}}`) } - const droppedProps = new Set(['fontSize', 'color']) + const droppedProps = new Set() + if (sizeNum) { + droppedProps.add('fontSize') + } const allAttrs = el.findAll({ rule: { kind: 'jsx_attribute' } }) for (const attr of allAttrs) { const propIdent = attr.find({ rule: { kind: 'property_identifier' } }) diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts index 547bc86..1857a18 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts @@ -20,6 +20,26 @@ const BOX_FLEX_PROP_MAP: Record = { /** Box props that trigger a TODO — polymorphic or complex usage. */ const BOX_TODO_PROPS = new Set(['component', 'clone', 'css', 'sx', 'classes']) +/** MUI spacing shorthand props — no deterministic BUI mapping yet. */ +const BOX_SPACING_PROPS = new Set([ + 'p', + 'px', + 'py', + 'pt', + 'pb', + 'pl', + 'pr', + 'm', + 'mx', + 'my', + 'mt', + 'mb', + 'ml', + 'mr', + 'padding', + 'margin', +]) + /** Paper props that trigger a TODO. */ const PAPER_TODO_PROPS = new Set(['variant', 'elevation', 'square', 'component', 'classes']) @@ -162,13 +182,10 @@ function removeUnusedLayoutImports( continue } - if ( - defaultName && + const defaultIsUnusedLayout = + defaultName !== null && localNames.has(defaultName) && !isMuiComponentStillUsed(rootNode, defaultName, migratedLocalNames) - ) { - // Default + named imports: drop the default binding when unused; trim named below. - } const remainingSpecifiers = allSpecifiers.filter((spec) => { const identifiers = spec.findAll({ @@ -186,10 +203,17 @@ function removeUnusedLayoutImports( return isMuiComponentStillUsed(rootNode, localName, migratedLocalNames) }) - if (remainingSpecifiers.length === 0) { + const importSource = imp.find({ rule: { kind: 'string_fragment' } })?.text() + + if (remainingSpecifiers.length === 0 && (defaultIsUnusedLayout || !defaultName)) { edits.push(imp.replace('')) removedImportIds.add(imp.id()) migrationMetric.increment({ action: 'import-removed' }) + } else if (defaultIsUnusedLayout && remainingSpecifiers.length > 0 && importSource) { + edits.push( + imp.replace(`import { ${remainingSpecifiers.map((s) => s.text()).join(', ')} } from '${importSource}';`), + ) + migrationMetric.increment({ action: 'import-trimmed' }) } else if (remainingSpecifiers.length < allSpecifiers.length) { const namedImports = imp.find({ rule: { kind: 'named_imports' } }) if (namedImports) { @@ -330,6 +354,14 @@ function isFlexBox(opening: SgNode): boolean { } function transformBoxElement(el: SgNode, opening: SgNode, edits: Edit[]): string | null { + for (const prop of BOX_SPACING_PROPS) { + if (hasProp(opening, prop)) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify BUI layout mapping manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'box-spacing' }) + return null + } + } + // Check for TODO-triggering props for (const prop of BOX_TODO_PROPS) { if (hasProp(opening, prop)) { @@ -379,15 +411,6 @@ function transformBoxElement(el: SgNode, opening: SgNode, edits: Edit[ if (handledProps.has(propName)) { continue } - // Skip MUI-only spacing shorthand props - if ( - ['p', 'px', 'py', 'pt', 'pb', 'pl', 'pr', 'm', 'mx', 'my', 'mt', 'mb', 'ml', 'mr', 'padding', 'margin'].includes( - propName, - ) - ) { - migrationMetric.increment({ action: 'spacing-prop-dropped', prop: propName }) - continue - } newProps.push(attr.text()) } @@ -574,6 +597,14 @@ function buildGridItemProps(opening: SgNode): { props: string[]; isTodo: bo return { props, isTodo: false } } +function replaceJsxOpeningTag(opening: SgNode, tagName: string, propsStr: string, edits: Edit[]): void { + edits.push(opening.replace(`<${tagName}${propsStr}>`)) +} + +function replaceJsxClosingTag(closing: SgNode, tagName: string, edits: Edit[]): void { + edits.push(closing.replace(``)) +} + function transformGridElement(el: SgNode, opening: SgNode, edits: Edit[]): string | null { for (const prop of GRID_TODO_PROPS) { if (hasProp(opening, prop)) { @@ -607,8 +638,11 @@ function transformGridElement(el: SgNode, opening: SgNode, edits: Edit if (isSelfClosing) { edits.push(el.replace(``)) } else { - const children = getChildContent(el) - edits.push(el.replace(`${children}`)) + replaceJsxOpeningTag(opening, 'Grid.Root', propsStr, edits) + const closing = el.children().find((child) => child.kind() === 'jsx_closing_element') + if (closing) { + replaceJsxClosingTag(closing, 'Grid.Root', edits) + } } migrationMetric.increment({ action: 'grid-container-to-root' }) @@ -627,8 +661,11 @@ function transformGridElement(el: SgNode, opening: SgNode, edits: Edit if (isSelfClosing) { edits.push(el.replace(``)) } else { - const children = getChildContent(el) - edits.push(el.replace(`${children}`)) + replaceJsxOpeningTag(opening, 'Grid.Item', propsStr, edits) + const closing = el.children().find((child) => child.kind() === 'jsx_closing_element') + if (closing) { + replaceJsxClosingTag(closing, 'Grid.Item', edits) + } } migrationMetric.increment({ action: 'grid-item-migrated' }) diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx index 3bc4c2a..9fc31b4 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/tests/grid-simple/expected.tsx @@ -2,7 +2,9 @@ import { Grid } from '@backstage/ui'; const MyComponent = () => ( - + + Content - + + ); diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts index 4d0be04..228ac09 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/scripts/codemod.ts @@ -57,6 +57,19 @@ function findImportStatementsFrom(rootNode: SgNode, source: string): SgNode }) } +function getDefaultImportName(imp: SgNode): 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, targetName: string): string | null { for (const spec of imp.findAll({ rule: { kind: 'import_specifier' } })) { const identifiers = spec.findAll({ @@ -98,6 +111,10 @@ function collectStylesImports(rootNode: SgNode): StylesImportInfo { } if (ms || cs || ws) { + if (getDefaultImportName(imp)) { + continue + } + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) const targetCount = (ms ? 1 : 0) + (cs ? 1 : 0) + (ws ? 1 : 0) if (targetCount >= allSpecifiers.length) { @@ -542,7 +559,7 @@ const transform: Codemod = async (root) => { const survivingImports = rootNode .findAll({ rule: { kind: 'import_statement' } }) .filter((imp) => !importsToRemoveIds.has(imp.id())) - const cssImportLine = `import styles from '${cssModuleImportPath}';\n` + const cssImportLine = `import styles from '${cssModuleImportPath}';` if (!hasCssModuleImport(rootNode, cssModuleImportPath)) { if (survivingImports.length > 0) { @@ -561,7 +578,7 @@ const transform: Codemod = async (root) => { edits.push({ startPos: firstNode.range().start.index, endPos: firstNode.range().start.index, - insertedText: cssImportLine, + insertedText: `${cssImportLine}\n`, }) } } From f68f3d3e9f0c619c692d128f15c99b07a10fea1b Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 10:58:13 -0500 Subject: [PATCH 10/12] feat: resolve latest BUI and Remix versions from npm in bootstrap codemod Look up @backstage/ui and @remixicon/react from the npm registry at run time instead of hard-coded defaults, with offline fallbacks for tests and fetch capability declared for production workflow runs. Co-authored-by: Cursor --- .../migrate-mui-bootstrap-to-bui/codemod.yaml | 3 +- .../scripts/package-json-codemod.ts | 50 +++++++++++++++++-- .../scripts/resolve-latest-version.ts | 32 ++++++++++++ .../tests/package-with-mui-core/metrics.json | 8 +++ .../tests/package-with-mui-icons/metrics.json | 16 ++++++ .../workflow.yaml | 10 ++-- 6 files changed, 109 insertions(+), 10 deletions(-) create mode 100644 codemods/misc/migrate-mui-bootstrap-to-bui/scripts/resolve-latest-version.ts diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/codemod.yaml b/codemods/misc/migrate-mui-bootstrap-to-bui/codemod.yaml index e807ae7..a3d6b61 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/codemod.yaml +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/codemod.yaml @@ -17,4 +17,5 @@ registry: access: 'public' visibility: 'public' -capabilities: [] +capabilities: + - fetch diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts index 0c75b7b..6fcd1fb 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts @@ -2,6 +2,8 @@ import type { Codemod } from 'codemod:ast-grep' import type JSON from 'codemod:ast-grep/langs/json' import { useMetricAtom } from 'codemod:metrics' +import { resolveLatestCaretRange } from './resolve-latest-version.ts' + const migrationMetric = useMetricAtom('migrate-mui-bootstrap-to-bui') const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/lab'] as const @@ -9,8 +11,9 @@ const MUI_PACKAGES = ['@material-ui/core', '@material-ui/icons', '@material-ui/l const BUI_PACKAGE = '@backstage/ui' const REMIX_PACKAGE = '@remixicon/react' -const DEFAULT_BUI_VERSION = '^0.16.0' -const DEFAULT_REMIX_VERSION = '^4.9.0' +/** Offline fallback when registry lookup is disabled or unavailable (e.g. jssg tests). */ +const FALLBACK_BUI_VERSION = '^0.16.0' +const FALLBACK_REMIX_VERSION = '^4.9.0' interface PackageJson { dependencies?: Record @@ -18,6 +21,12 @@ interface PackageJson { [key: string]: unknown } +interface PackageJsonCodemodParams { + resolveLatestVersions?: boolean + buiVersion?: string + remixVersion?: string +} + function sortObjectKeys(obj: Record): Record { const sorted: Record = {} for (const key of Object.keys(obj).sort()) { @@ -54,12 +63,35 @@ function dependencySectionForMui(pkg: PackageJson): 'dependencies' | 'devDepende return 'devDependencies' } +async function resolveDependencyVersion( + packageName: string, + params: PackageJsonCodemodParams, + paramOverride: string | undefined, + fallback: string, +): Promise { + if (paramOverride !== undefined) { + return paramOverride + } + + if (params.resolveLatestVersions === false) { + migrationMetric.increment({ action: 'version-fallback', package: packageName, reason: 'disabled' }) + return fallback + } + + const latest = await resolveLatestCaretRange(packageName) + if (latest !== null) { + migrationMetric.increment({ action: 'version-resolved', package: packageName, version: latest }) + return latest + } + + migrationMetric.increment({ action: 'version-fallback', package: packageName, reason: 'registry-unavailable' }) + return fallback +} + const transform: Codemod = async (root, options) => { const rootNode = root.root() const source = normalizeSource(rootNode.text()) - - const buiVersion = options.params.buiVersion ?? DEFAULT_BUI_VERSION - const remixVersion = options.params.remixVersion ?? DEFAULT_REMIX_VERSION + const params = options.params as PackageJsonCodemodParams let pkg: PackageJson try { @@ -72,6 +104,8 @@ const transform: Codemod = async (root, options) => { return Promise.resolve(null) } + const buiVersion = await resolveDependencyVersion(BUI_PACKAGE, params, params.buiVersion, FALLBACK_BUI_VERSION) + const section = dependencySectionForMui(pkg) const existingDeps = pkg[section] ?? {} let changed = false @@ -83,6 +117,12 @@ const transform: Codemod = async (root, options) => { } if (hasMuiIcons(pkg) && existingDeps[REMIX_PACKAGE] === undefined) { + const remixVersion = await resolveDependencyVersion( + REMIX_PACKAGE, + params, + params.remixVersion, + FALLBACK_REMIX_VERSION, + ) existingDeps[REMIX_PACKAGE] = remixVersion changed = true migrationMetric.increment({ action: 'remix-dependency-added' }) diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/resolve-latest-version.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/resolve-latest-version.ts new file mode 100644 index 0000000..814e484 --- /dev/null +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/resolve-latest-version.ts @@ -0,0 +1,32 @@ +const NPM_REGISTRY = 'https://registry.npmjs.org' + +const versionResolutionCache = new Map() + +interface NpmLatestResponse { + version?: string +} + +export async function resolveLatestCaretRange(packageName: string): Promise { + const cached = versionResolutionCache.get(packageName) + if (cached !== undefined) { + return cached + } + + try { + const response = await fetch(`${NPM_REGISTRY}/${encodeURIComponent(packageName)}/latest`) + if (!response.ok) { + return null + } + + const payload = (await response.json()) as NpmLatestResponse + if (payload.version === undefined) { + return null + } + + const range = `^${payload.version}` + versionResolutionCache.set(packageName, range) + return range + } catch { + return null + } +} diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json index 04200ce..b67df50 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json @@ -5,6 +5,14 @@ "action": "bui-dependency-added" }, "count": 1 + }, + { + "cardinality": { + "action": "version-fallback", + "package": "@backstage/ui", + "reason": "registry-unavailable" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json index 2925f7c..779cd63 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json @@ -11,6 +11,22 @@ "action": "remix-dependency-added" }, "count": 1 + }, + { + "cardinality": { + "action": "version-fallback", + "package": "@backstage/ui", + "reason": "registry-unavailable" + }, + "count": 1 + }, + { + "cardinality": { + "action": "version-fallback", + "package": "@remixicon/react", + "reason": "registry-unavailable" + }, + "count": 1 } ] } \ No newline at end of file diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml b/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml index 5a3a732..5696ce0 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/workflow.yaml @@ -4,14 +4,16 @@ version: '1' params: schema: + resolveLatestVersions: + type: boolean + default: true + description: 'Resolve @backstage/ui and @remixicon/react versions from the npm registry (latest release)' buiVersion: type: string - default: '^0.16.0' - description: 'Semver range for @backstage/ui (defaults match the MUI foundation migration recipe)' + description: 'Override the semver range for @backstage/ui instead of resolving latest from npm' remixVersion: type: string - default: '^4.9.0' - description: 'Semver range for @remixicon/react when @material-ui/icons is present' + description: 'Override the semver range for @remixicon/react instead of resolving latest from npm' nodes: - id: apply-transforms From c2351929411b21757c045245b3f8021022db9524 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 11:02:46 -0500 Subject: [PATCH 11/12] fix: only resolve npm versions when workflow explicitly enables it jssg tests run without workflow params but CI grants fetch via the codemod capability, causing flaky version-resolved metrics. Require resolveLatestVersions: true (the workflow default) before hitting npm. Co-authored-by: Cursor --- .../scripts/package-json-codemod.ts | 2 +- .../tests/package-with-mui-core/metrics.json | 2 +- .../tests/package-with-mui-icons/metrics.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts index 6fcd1fb..14a02f1 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/package-json-codemod.ts @@ -73,7 +73,7 @@ async function resolveDependencyVersion( return paramOverride } - if (params.resolveLatestVersions === false) { + if (params.resolveLatestVersions !== true) { migrationMetric.increment({ action: 'version-fallback', package: packageName, reason: 'disabled' }) return fallback } diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json index b67df50..4c6b4e7 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-core/metrics.json @@ -10,7 +10,7 @@ "cardinality": { "action": "version-fallback", "package": "@backstage/ui", - "reason": "registry-unavailable" + "reason": "disabled" }, "count": 1 } diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json index 779cd63..46a1450 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/tests/package-with-mui-icons/metrics.json @@ -16,7 +16,7 @@ "cardinality": { "action": "version-fallback", "package": "@backstage/ui", - "reason": "registry-unavailable" + "reason": "disabled" }, "count": 1 }, @@ -24,7 +24,7 @@ "cardinality": { "action": "version-fallback", "package": "@remixicon/react", - "reason": "registry-unavailable" + "reason": "disabled" }, "count": 1 } From 388f591f34ceb63aa9be96a861692802257dee96 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 11:11:08 -0500 Subject: [PATCH 12/12] fix: address Copilot review feedback on foundation codemods Narrow bootstrap entry detection to known app/plugin paths, restrict icon slot wrapping to Blueprint.make config objects, treat Grid container={false} as disabled, scope MUI dependency scans to src/ and dev/, and add initial 0.1.0 CHANGELOG entries for the new misc packages. Co-authored-by: Cursor --- .../migrate-mui-bootstrap-to-bui/CHANGELOG.md | 6 +++ .../scripts/codemod.ts | 2 +- .../CHANGELOG.md | 6 +++ .../scripts/codemod.ts | 43 +++++++++++++++++++ .../tests/extension-icon-slot/expected.tsx | 14 +++--- .../tests/extension-icon-slot/input.tsx | 14 +++--- .../CHANGELOG.md | 6 +++ .../scripts/codemod.ts | 13 +++++- .../CHANGELOG.md | 6 +++ .../remove-mui-dependencies/workflow.yaml | 6 ++- 10 files changed, 102 insertions(+), 14 deletions(-) diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/CHANGELOG.md b/codemods/misc/migrate-mui-bootstrap-to-bui/CHANGELOG.md index dc8dae2..9fe0435 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/CHANGELOG.md +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/CHANGELOG.md @@ -1 +1,7 @@ # @backstage/migrate-mui-bootstrap-to-bui + +## 0.1.0 + +### Minor Changes + +- Initial release: add `@backstage/ui` and `@remixicon/react` dependencies to `package.json` and insert the global BUI stylesheet in app/plugin entry files during the MUI 4 to BUI migration. diff --git a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts index 08a8182..5882d49 100644 --- a/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-bootstrap-to-bui/scripts/codemod.ts @@ -31,7 +31,7 @@ function normalizeFilePath(filename: string): string { function isAppEntryFile(filename: string, rootNode: SgNode): boolean { const normalized = normalizeFilePath(filename) - if (/(?:^|\/)App\.tsx?$/.test(normalized)) { + if (/(?:^|\/)packages\/app\/src\/App\.tsx?$/.test(normalized)) { return true } if (/(?:^|\/)src\/index\.tsx?$/.test(normalized)) { diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/CHANGELOG.md b/codemods/misc/migrate-mui-icons-to-remix-icons/CHANGELOG.md index 00ba8f9..323dc94 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/CHANGELOG.md +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/CHANGELOG.md @@ -1 +1,7 @@ # @backstage/migrate-mui-icons-to-remix-icons + +## 0.1.0 + +### Minor Changes + +- Initial release: replace common `@material-ui/icons` imports with `@remixicon/react` equivalents and wrap extension config icon slots for Backstage blueprints. diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts index 11fef2c..3cfff2e 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/scripts/codemod.ts @@ -356,6 +356,45 @@ function transformIconJsx(rootNode: SgNode, iconLocalNames: Set, ed } } +function isBlueprintMakeCall(call: SgNode): boolean { + const fn = call.field('function') + if (fn?.kind() !== 'member_expression') { + return false + } + + const method = fn.find({ rule: { kind: 'property_identifier' } })?.text() + if (method !== 'make' && method !== 'makeWithOverrides') { + return false + } + + return /Blueprint\.(?:make|makeWithOverrides)$/.test(fn.text().replaceAll(/\s/g, '')) +} + +function isObjectInExtensionConfig(objectNode: SgNode): boolean { + let current: SgNode | null = objectNode + while (current) { + if (current.kind() === 'arguments') { + const call = current.parent() + if (call?.kind() === 'call_expression' && isBlueprintMakeCall(call)) { + return true + } + } + current = current.parent() + } + return false +} + +function isExtensionConfigIconPair(pair: SgNode): boolean { + let current: SgNode | null = pair + while (current) { + if (current.kind() === 'object' && isObjectInExtensionConfig(current)) { + return true + } + current = current.parent() + } + return false +} + function transformExtensionIconSlots(rootNode: SgNode, iconLocalNames: Set, edits: Edit[]): void { const iconPairs = rootNode.findAll({ rule: { @@ -368,6 +407,10 @@ function transformExtensionIconSlots(rootNode: SgNode, iconLocalNames: Set< }) for (const pair of iconPairs) { + if (!isExtensionConfigIconPair(pair)) { + continue + } + const valueNode = pair.field('value') if (!valueNode || !valueNode.is('identifier')) { continue diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx index 7111c27..9725c77 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/expected.tsx @@ -1,8 +1,12 @@ +import { PageBlueprint, createRouteRef } from '@backstage/frontend-plugin-api'; import { RiSearchLine as SearchIcon } from '@remixicon/react'; -const navItem = { - title: 'Search', - icon: () => , - routeRef: searchRouteRef, -}; +const searchRouteRef = createRouteRef(); + +export const searchPage = PageBlueprint.make({ + params: { + icon: () => , + routeRef: searchRouteRef, + }, +}); diff --git a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/input.tsx b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/input.tsx index 8ad01a0..5c589d0 100644 --- a/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/input.tsx +++ b/codemods/misc/migrate-mui-icons-to-remix-icons/tests/extension-icon-slot/input.tsx @@ -1,7 +1,11 @@ import SearchIcon from '@material-ui/icons/Search'; +import { PageBlueprint, createRouteRef } from '@backstage/frontend-plugin-api'; -const navItem = { - title: 'Search', - icon: SearchIcon, - routeRef: searchRouteRef, -}; +const searchRouteRef = createRouteRef(); + +export const searchPage = PageBlueprint.make({ + params: { + icon: SearchIcon, + routeRef: searchRouteRef, + }, +}); diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/CHANGELOG.md b/codemods/misc/migrate-mui-layout-to-bui-layout/CHANGELOG.md index afb1b9d..277922e 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/CHANGELOG.md +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/CHANGELOG.md @@ -1 +1,7 @@ # @backstage/migrate-mui-layout-to-bui-layout + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI layout primitives (`Box`, `Paper`, `Grid`) to Backstage UI layout components during the MUI 4 to BUI migration. diff --git a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts index 1857a18..715edaf 100644 --- a/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-layout-to-bui-layout/scripts/codemod.ts @@ -491,7 +491,18 @@ const GRID_TODO_PROPS = new Set([ ]) function hasBooleanProp(opening: SgNode, propName: string): boolean { - return getPropAttr(opening, propName) !== null + const attr = getPropAttr(opening, propName) + if (!attr) { + return false + } + + const jsxExpr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!jsxExpr) { + return true + } + + const inner = jsxExpr.text().slice(1, -1).trim() + return inner !== 'false' } function getPropStaticNumericValue(opening: SgNode, propName: string): string | null { diff --git a/codemods/misc/migrate-mui-styles-to-bui-css-modules/CHANGELOG.md b/codemods/misc/migrate-mui-styles-to-bui-css-modules/CHANGELOG.md index 50d3239..71ebc5c 100644 --- a/codemods/misc/migrate-mui-styles-to-bui-css-modules/CHANGELOG.md +++ b/codemods/misc/migrate-mui-styles-to-bui-css-modules/CHANGELOG.md @@ -1 +1,7 @@ # @backstage/migrate-mui-styles-to-bui-css-modules + +## 0.1.0 + +### Minor Changes + +- Initial release: extract static `makeStyles` / `withStyles` rules into adjacent CSS modules during the MUI 4 to BUI migration. diff --git a/codemods/misc/remove-mui-dependencies/workflow.yaml b/codemods/misc/remove-mui-dependencies/workflow.yaml index 05c2d01..8e28566 100644 --- a/codemods/misc/remove-mui-dependencies/workflow.yaml +++ b/codemods/misc/remove-mui-dependencies/workflow.yaml @@ -13,8 +13,10 @@ nodes: language: 'tsx' semantic_analysis: file include: - - '**/*.ts' - - '**/*.tsx' + - '**/src/**/*.ts' + - '**/src/**/*.tsx' + - '**/dev/**/*.ts' + - '**/dev/**/*.tsx' exclude: - '**/node_modules/**' - name: 'Remove @material-ui/* packages when no longer imported in the package'