From 288827bed565941f45e0e99763ed54006ee627a6 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 21:22:04 -0500 Subject: [PATCH 1/6] feat: MUI 4 to BUI migration - core component codemods Add 5 core component codemods: - migrate-mui-typography-to-text (#112) - migrate-mui-alert-to-bui-alert (#113) - migrate-mui-button-to-bui-button (#114) - migrate-mui-icon-button-to-button-icon (#115) - migrate-mui-tooltip-to-bui-tooltip (#116) Closes #112, #113, #114, #115, #116 --- .changeset/mui-to-bui-core-components.md | 9 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 368 ++++++++++++++ .../tests/core-import/expected.tsx | 5 + .../tests/core-import/input.tsx | 5 + .../tests/core-import/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 23 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tests/noop-no-mui-import/expected.tsx | 5 + .../tests/noop-no-mui-import/input.tsx | 5 + .../tests/simple-warning/expected.tsx | 5 + .../tests/simple-warning/input.tsx | 5 + .../tests/simple-warning/metrics.json | 23 + .../tests/with-title/expected.tsx | 6 + .../tests/with-title/input.tsx | 9 + .../tests/with-title/metrics.json | 23 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 354 +++++++++++++ .../tests/color-primary-ok/expected.tsx | 5 + .../tests/color-primary-ok/input.tsx | 5 + .../tests/color-primary-ok/metrics.json | 23 + .../tests/color-secondary-todo/expected.tsx | 6 + .../tests/color-secondary-todo/input.tsx | 5 + .../tests/color-secondary-todo/metrics.json | 23 + .../tests/contained-button/expected.tsx | 7 + .../tests/contained-button/input.tsx | 7 + .../tests/contained-button/metrics.json | 23 + .../tests/disabled-boolean/expected.tsx | 5 + .../tests/disabled-boolean/input.tsx | 5 + .../tests/disabled-boolean/metrics.json | 23 + .../tests/dynamic-variant-todo/expected.tsx | 6 + .../tests/dynamic-variant-todo/input.tsx | 5 + .../tests/dynamic-variant-todo/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 23 + .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 5 + .../tests/named-barrel-import/metrics.json | 23 + .../tests/no-variant/expected.tsx | 5 + .../tests/no-variant/input.tsx | 5 + .../tests/no-variant/metrics.json | 23 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tests/outlined-button/expected.tsx | 5 + .../tests/outlined-button/input.tsx | 5 + .../tests/outlined-button/metrics.json | 23 + .../tests/self-closing/expected.tsx | 5 + .../tests/self-closing/input.tsx | 5 + .../tests/self-closing/metrics.json | 23 + .../tests/spread-props/expected.tsx | 5 + .../tests/spread-props/input.tsx | 5 + .../tests/spread-props/metrics.json | 23 + .../tests/start-icon-todo/expected.tsx | 6 + .../tests/start-icon-todo/input.tsx | 5 + .../tests/start-icon-todo/metrics.json | 23 + .../tests/text-button/expected.tsx | 5 + .../tests/text-button/input.tsx | 5 + .../tests/text-button/metrics.json | 23 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 303 ++++++++++++ .../tests/basic-migration/expected.tsx | 5 + .../tests/basic-migration/input.tsx | 7 + .../tests/basic-migration/metrics.json | 22 + .../tests/className-preserved/expected.tsx | 5 + .../tests/className-preserved/input.tsx | 7 + .../tests/className-preserved/metrics.json | 22 + .../tests/complex-children-todo/expected.tsx | 9 + .../tests/complex-children-todo/input.tsx | 8 + .../tests/complex-children-todo/metrics.json | 23 + .../tests/disabled-boolean/expected.tsx | 5 + .../tests/disabled-boolean/input.tsx | 7 + .../tests/disabled-boolean/metrics.json | 22 + .../tests/dropped-props/expected.tsx | 5 + .../tests/dropped-props/input.tsx | 7 + .../tests/dropped-props/metrics.json | 43 ++ .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 11 + .../tests/merge-existing-bui/metrics.json | 22 + .../missing-aria-label-todo/expected.tsx | 8 + .../tests/missing-aria-label-todo/input.tsx | 7 + .../missing-aria-label-todo/metrics.json | 23 + .../tests/named-barrel-import/expected.tsx | 5 + .../tests/named-barrel-import/input.tsx | 7 + .../tests/named-barrel-import/metrics.json | 22 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tests/spread-props/expected.tsx | 5 + .../tests/spread-props/input.tsx | 7 + .../tests/spread-props/metrics.json | 22 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 401 +++++++++++++++ .../tests/controlled-todo/expected.tsx | 8 + .../tests/controlled-todo/input.tsx | 7 + .../tests/controlled-todo/metrics.json | 28 ++ .../tests/dynamic-title/expected.tsx | 8 + .../tests/dynamic-title/input.tsx | 7 + .../tests/dynamic-title/metrics.json | 22 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 8 + .../tests/merge-existing-bui/metrics.json | 22 + .../tests/named-barrel-import/expected.tsx | 8 + .../tests/named-barrel-import/input.tsx | 7 + .../tests/named-barrel-import/metrics.json | 22 + .../tests/noop-no-import/expected.tsx | 8 + .../tests/noop-no-import/input.tsx | 8 + .../tests/placement-dropped/expected.tsx | 9 + .../tests/placement-dropped/input.tsx | 7 + .../tests/placement-dropped/metrics.json | 29 ++ .../tests/simple-wrapper/expected.tsx | 10 + .../tests/simple-wrapper/input.tsx | 9 + .../tests/simple-wrapper/metrics.json | 22 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + .../CHANGELOG.md | 1 + .../codemod.yaml | 20 + .../package.json | 13 + .../scripts/codemod.ts | 465 ++++++++++++++++++ .../tests/basic-body-text/expected.tsx | 5 + .../tests/basic-body-text/input.tsx | 5 + .../tests/basic-body-text/metrics.json | 23 + .../tests/component-to-as/expected.tsx | 5 + .../tests/component-to-as/input.tsx | 5 + .../tests/component-to-as/metrics.json | 23 + .../tests/dialog-content-text/expected.tsx | 5 + .../tests/dialog-content-text/input.tsx | 5 + .../tests/dialog-content-text/metrics.json | 23 + .../tests/extra-props-preserved/expected.tsx | 5 + .../tests/extra-props-preserved/input.tsx | 5 + .../tests/extra-props-preserved/metrics.json | 23 + .../tests/gutter-bottom-dropped/expected.tsx | 5 + .../tests/gutter-bottom-dropped/input.tsx | 5 + .../tests/gutter-bottom-dropped/metrics.json | 29 ++ .../tests/heading-variant/expected.tsx | 5 + .../tests/heading-variant/input.tsx | 5 + .../tests/heading-variant/metrics.json | 23 + .../tests/merge-existing-bui/expected.tsx | 9 + .../tests/merge-existing-bui/input.tsx | 9 + .../tests/merge-existing-bui/metrics.json | 23 + .../tests/named-barrel-import/expected.tsx | 9 + .../tests/named-barrel-import/input.tsx | 8 + .../tests/named-barrel-import/metrics.json | 23 + .../tests/noop-no-import/expected.tsx | 5 + .../tests/noop-no-import/input.tsx | 5 + .../tests/noop-no-mui-import/expected.tsx | 5 + .../tests/noop-no-mui-import/input.tsx | 5 + .../tests/preserve-children/expected.tsx | 7 + .../tests/preserve-children/input.tsx | 7 + .../tests/preserve-children/metrics.json | 23 + .../tests/unmapped-variant-todo/expected.tsx | 6 + .../tests/unmapped-variant-todo/input.tsx | 5 + .../tests/unmapped-variant-todo/metrics.json | 11 + .../tsconfig.json | 16 + .../workflow.yaml | 19 + yarn.lock | 45 ++ 172 files changed, 3886 insertions(+) create mode 100644 .changeset/mui-to-bui-core-components.md create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/package.json create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/input.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/metrics.json create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/input.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/input.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/metrics.json create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/input.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/metrics.json create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tsconfig.json create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/workflow.yaml create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/package.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/input.tsx create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/tsconfig.json create mode 100644 codemods/misc/migrate-mui-button-to-bui-button/workflow.yaml create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/package.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/input.tsx create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/tsconfig.json create mode 100644 codemods/misc/migrate-mui-icon-button-to-button-icon/workflow.yaml create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/package.json create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/expected.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/input.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/metrics.json create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/expected.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/input.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/metrics.json create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/expected.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/input.tsx create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/metrics.json create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tsconfig.json create mode 100644 codemods/misc/migrate-mui-tooltip-to-bui-tooltip/workflow.yaml create mode 100644 codemods/misc/migrate-mui-typography-to-text/CHANGELOG.md create mode 100644 codemods/misc/migrate-mui-typography-to-text/codemod.yaml create mode 100644 codemods/misc/migrate-mui-typography-to-text/package.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/input.tsx create mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/tsconfig.json create mode 100644 codemods/misc/migrate-mui-typography-to-text/workflow.yaml diff --git a/.changeset/mui-to-bui-core-components.md b/.changeset/mui-to-bui-core-components.md new file mode 100644 index 0000000..496f550 --- /dev/null +++ b/.changeset/mui-to-bui-core-components.md @@ -0,0 +1,9 @@ +--- +'@backstage/migrate-mui-typography-to-text': minor +'@backstage/migrate-mui-alert-to-bui-alert': minor +'@backstage/migrate-mui-button-to-bui-button': minor +'@backstage/migrate-mui-icon-button-to-button-icon': minor +'@backstage/migrate-mui-tooltip-to-bui-tooltip': minor +--- + +Add core component codemods for the MUI 4 to BUI migration: Typography to Text, Alert, Button, IconButton to ButtonIcon, and Tooltip to TooltipTrigger. diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md b/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md new file mode 100644 index 0000000..f814c62 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-alert-to-bui-alert diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml b/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml new file mode 100644 index 0000000..8c43006 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-alert-to-bui-alert' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace MUI Alert with BUI Alert' +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', 'alert', 'bui', 'alert'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/package.json b/codemods/misc/migrate-mui-alert-to-bui-alert/package.json new file mode 100644 index 0000000..eea18bc --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-alert-to-bui-alert", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace MUI Alert with BUI Alert", + "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-alert-to-bui-alert/scripts/codemod.ts b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts new file mode 100644 index 0000000..2c1d276 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts @@ -0,0 +1,368 @@ +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-alert-to-bui-alert') + +const SEVERITY_TO_STATUS: Record = { + error: 'danger', + warning: 'warning', + info: 'info', + success: 'success', +} + +const BUI_SOURCE = '@backstage/ui' + +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 +} + +function collectAlertImports(rootNode: SgNode): { + alertLocalName: string | null + alertTitleLocalName: string | null + importNodesToRemove: SgNode[] +} { + let alertLocalName: string | null = null + let alertTitleLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab/Alert')) { + alertLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Alert')) { + alertLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab/AlertTitle')) { + alertTitleLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const alertName = getNamedImportLocalName(imp, 'Alert') + if (alertName) { + alertLocalName = alertName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } + } + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab')) { + const alertName = getNamedImportLocalName(imp, 'Alert') + const alertTitleName = getNamedImportLocalName(imp, 'AlertTitle') + + if (alertName) { + alertLocalName = alertName + } + if (alertTitleName) { + alertTitleLocalName = alertTitleName + } + + if (alertName || alertTitleName) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + const alertSpecCount = (alertName ? 1 : 0) + (alertTitleName ? 1 : 0) + if (alertSpecCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } + } + } + + return { alertLocalName, alertTitleLocalName, importNodesToRemove } +} + +function buildBuiImportEdit(rootNode: SgNode, edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + let hasAlert = false + for (const spec of specifiers) { + const idents = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + if (idents[0]?.text() === 'Alert') { + hasAlert = true + } + } + if (!hasAlert) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + names.push('Alert') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } + } 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 { Alert } from '${BUI_SOURCE}';`)) + } + } + migrationMetric.increment({ action: 'import-added' }) + } +} + +function getSeverityValue(opening: SgNode): { value: string | null; isDynamic: boolean } { + const severityAttr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: '^severity$', + }, + }, + }) + + if (!severityAttr) { + return { value: null, isDynamic: false } + } + + const stringNode = severityAttr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return { value: frag?.text() ?? null, isDynamic: false } + } + + const exprNode = severityAttr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + return { value: exprNode.text(), isDynamic: true } + } + + return { value: null, isDynamic: false } +} + +function hasProp(opening: SgNode, propName: string): boolean { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + return attr !== null +} + +function extractChildContent( + element: SgNode, + alertTitleLocalName: string | null, +): { title: string | null; description: string | null; hasComplexContent: boolean } { + if (!element.is('jsx_element')) { + return { title: null, description: null, hasComplexContent: false } + } + + let title: string | null = null + let hasComplexContent = false + const descriptionParts: string[] = [] + + for (const child of element.children()) { + const kind = child.kind() + + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + + if (kind === 'jsx_text') { + const trimmed = child.text().trim() + if (trimmed.length > 0) { + descriptionParts.push(trimmed) + } + continue + } + + if (kind === 'jsx_element' && alertTitleLocalName) { + const opening = child.child(0) + const nameNode = opening?.child(1) + if (nameNode?.text() === alertTitleLocalName) { + const titleParts: string[] = [] + for (const titleChild of child.children()) { + if (titleChild.kind() === 'jsx_opening_element' || titleChild.kind() === 'jsx_closing_element') { + continue + } + if (titleChild.kind() === 'jsx_text') { + const t = titleChild.text().trim() + if (t.length > 0) { + titleParts.push(t) + } + } else { + hasComplexContent = true + } + } + if (titleParts.length > 0) { + title = titleParts.join(' ') + } + continue + } + } + + if (kind === 'jsx_element' || kind === 'jsx_self_closing_element' || kind === 'jsx_expression') { + hasComplexContent = true + continue + } + } + + const description = descriptionParts.length > 0 ? descriptionParts.join(' ') : null + return { title, description, hasComplexContent } +} + +function transformAlertElements( + rootNode: SgNode, + alertLocalName: string, + alertTitleLocalName: string | null, + edits: Edit[], +): void { + 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 nameNode = opening.child(1) + if (!nameNode || nameNode.text() !== alertLocalName) { + continue + } + + if (hasProp(opening, 'action') || hasProp(opening, 'onClose')) { + edits.push( + el.replace(`{/* TODO(backstage-codemod): migrate Alert actions or complex children manually */}\n${el.text()}`), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'action-or-onClose' }) + continue + } + + const { value: severityValue, isDynamic } = getSeverityValue(opening) + + if (isDynamic) { + edits.push( + el.replace(`{/* TODO(backstage-codemod): migrate Alert actions or complex children manually */}\n${el.text()}`), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-severity' }) + continue + } + + const status = severityValue ? (SEVERITY_TO_STATUS[severityValue] ?? severityValue) : null + + if (isSelfClosing) { + const props: string[] = [] + if (status) { + props.push(`status="${status}"`) + } + props.push('icon') + edits.push(el.replace(``)) + migrationMetric.increment({ action: 'alert-migrated', variant: 'self-closing' }) + continue + } + + const { title, description, hasComplexContent } = extractChildContent(el, alertTitleLocalName) + + if (hasComplexContent) { + edits.push( + el.replace(`{/* TODO(backstage-codemod): migrate Alert actions or complex children manually */}\n${el.text()}`), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-children' }) + continue + } + + const props: string[] = [] + if (status) { + props.push(`status="${status}"`) + } + props.push('icon') + if (title) { + props.push(`title="${title}"`) + } + if (description) { + props.push(`description="${description}"`) + } + edits.push(el.replace(``)) + migrationMetric.increment({ action: 'alert-migrated', variant: title ? 'with-title' : 'simple' }) + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { alertLocalName, alertTitleLocalName, importNodesToRemove } = collectAlertImports(rootNode) + + if (!alertLocalName) { + return null + } + + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + buildBuiImportEdit(rootNode, edits) + transformAlertElements(rootNode, alertLocalName, alertTitleLocalName, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx new file mode 100644 index 0000000..6a03291 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/input.tsx new file mode 100644 index 0000000..605c01a --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/input.tsx @@ -0,0 +1,5 @@ +import Alert from '@material-ui/core/Alert'; + +const MyComponent = () => ( + Note +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/metrics.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/metrics.json new file mode 100644 index 0000000..bb49a48 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-alert-to-bui-alert": [ + { + "cardinality": { + "action": "alert-migrated", + "variant": "simple" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..c4e04d1 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Alert, Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..651450f --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Alert from '@material-ui/lab/Alert'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Saved + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..6f8e059 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/merge-existing-bui/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-alert-to-bui-alert": [ + { + "cardinality": { + "action": "alert-migrated", + "variant": "simple" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..465c4b2 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..465c4b2 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/expected.tsx new file mode 100644 index 0000000..465c4b2 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/expected.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/input.tsx new file mode 100644 index 0000000..465c4b2 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/input.tsx @@ -0,0 +1,5 @@ +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx new file mode 100644 index 0000000..f885194 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/input.tsx new file mode 100644 index 0000000..1e8dbe6 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/input.tsx @@ -0,0 +1,5 @@ +import Alert from '@material-ui/lab/Alert'; + +const MyComponent = () => ( + Heads up +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/metrics.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/metrics.json new file mode 100644 index 0000000..bb49a48 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-alert-to-bui-alert": [ + { + "cardinality": { + "action": "alert-migrated", + "variant": "simple" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx new file mode 100644 index 0000000..34b8135 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx @@ -0,0 +1,6 @@ + + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/input.tsx new file mode 100644 index 0000000..d8e24ef --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/input.tsx @@ -0,0 +1,9 @@ +import Alert from '@material-ui/lab/Alert'; +import AlertTitle from '@material-ui/lab/AlertTitle'; + +const MyComponent = () => ( + + Error + Something failed. + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/metrics.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/metrics.json new file mode 100644 index 0000000..d6951da --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-alert-to-bui-alert": [ + { + "cardinality": { + "action": "alert-migrated", + "variant": "with-title" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 2 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tsconfig.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/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-alert-to-bui-alert/workflow.yaml b/codemods/misc/migrate-mui-alert-to-bui-alert/workflow.yaml new file mode 100644 index 0000000..bd04f9c --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/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 Alert with BUI Alert' + 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-button-to-bui-button/CHANGELOG.md b/codemods/misc/migrate-mui-button-to-bui-button/CHANGELOG.md new file mode 100644 index 0000000..e312afe --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-button-to-bui-button diff --git a/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml b/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml new file mode 100644 index 0000000..be1005b --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-button-to-bui-button' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace MUI Button with BUI Button' +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', 'button', 'bui', 'button'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-button-to-bui-button/package.json b/codemods/misc/migrate-mui-button-to-bui-button/package.json new file mode 100644 index 0000000..1aff35b --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-button-to-bui-button", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace MUI Button with BUI Button", + "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-button-to-bui-button/scripts/codemod.ts b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts new file mode 100644 index 0000000..b399cc7 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts @@ -0,0 +1,354 @@ +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-button-to-bui-button') + +const VARIANT_MAP: Record = { + contained: 'primary', + outlined: 'secondary', + text: 'tertiary', +} + +const BUI_SOURCE = '@backstage/ui' + +/** Props that need TODO markers because their semantics don't map mechanically. */ +const TODO_PROPS = new Set([ + 'startIcon', + 'endIcon', + 'href', + 'component', + 'fullWidth', + 'disableElevation', + 'disableRipple', + 'disableFocusRipple', +]) + +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 +} + +function collectButtonImports(rootNode: SgNode): { + buttonLocalName: string | null + importNodesToRemove: SgNode[] +} { + let buttonLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + + // Default import: import Button from '@material-ui/core/Button' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Button')) { + buttonLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + // Named import from barrel: import { Button } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'Button') + if (localName) { + buttonLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } + } + } + + return { buttonLocalName, importNodesToRemove } +} + +function addButtonToBuiImport(rootNode: SgNode, edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + let hasButton = false + for (const spec of specifiers) { + const idents = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + if (idents[0]?.text() === 'Button') { + hasButton = true + } + } + if (!hasButton) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + names.push('Button') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } + } 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 { Button } 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 hasProp(opening: SgNode, propName: string): boolean { + return ( + opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) !== 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 isPropDynamic(opening: SgNode, propName: string): boolean { + const attr = getPropAttr(opening, propName) + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'jsx_expression' } }) !== null +} + +function transformButtonElements(rootNode: SgNode, buttonLocalName: string, edits: Edit[]): void { + 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 !== buttonLocalName) { + continue + } + + // Check for TODO-triggering props + let needsTodo = false + const todoReasons: string[] = [] + + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + todoReasons.push(prop) + } + } + + // Dynamic variant — cannot map deterministically + if (isPropDynamic(opening, 'variant')) { + needsTodo = true + todoReasons.push('dynamic-variant') + } + + // Non-default color implies semantic intent + const colorValue = getPropStringValue(opening, 'color') + if (colorValue && colorValue !== 'primary' && colorValue !== 'default') { + needsTodo = true + todoReasons.push(`color-${colorValue}`) + } + if (isPropDynamic(opening, 'color')) { + needsTodo = true + todoReasons.push('dynamic-color') + } + + if (needsTodo) { + edits.push( + el.replace( + `{/* TODO(backstage-codemod): verify Button intent manually (${todoReasons.join(', ')}) */}\n${el.text()}`, + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) + continue + } + + // Build new props + const newProps: string[] = [] + + // Map variant + const variantValue = getPropStringValue(opening, 'variant') + if (variantValue) { + const buiVariant = VARIANT_MAP[variantValue] + if (buiVariant) { + newProps.push(`variant="${buiVariant}"`) + } else { + // Unknown static variant — keep as-is with TODO + edits.push( + el.replace(`{/* TODO(backstage-codemod): verify Button intent manually (unknown-variant) */}\n${el.text()}`), + ) + migrationMetric.increment({ action: 'todo-inserted', reason: 'unknown-variant' }) + continue + } + } + + // Map disabled → isDisabled + const disabledAttr = getPropAttr(opening, 'disabled') + if (disabledAttr) { + const exprNode = disabledAttr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + // disabled={expr} → isDisabled={expr} + newProps.push(`isDisabled=${exprNode.text()}`) + } else { + // Boolean shorthand: disabled → isDisabled + newProps.push('isDisabled') + } + } + + // Preserve all other safe props as-is (onClick, className, etc.) + const handledProps = new Set(['variant', 'disabled', 'color']) + 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 + } + 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(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + // Preserve children via AST traversal + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + edits.push(el.replace(`${children}`)) + } + + migrationMetric.increment({ action: 'button-migrated', variant: variantValue ?? 'default' }) + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { buttonLocalName, importNodesToRemove } = collectButtonImports(rootNode) + + if (!buttonLocalName) { + return null + } + + // Remove MUI imports + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + // Add BUI import + addButtonToBuiImport(rootNode, edits) + + // Transform JSX elements + transformButtonElements(rootNode, buttonLocalName, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx new file mode 100644 index 0000000..5dbc899 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/input.tsx new file mode 100644 index 0000000..b749435 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json new file mode 100644 index 0000000..29e0200 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx new file mode 100644 index 0000000..bd8a176 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx @@ -0,0 +1,6 @@ + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify Button intent manually (color-secondary) */} + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/input.tsx new file mode 100644 index 0000000..3da3ab8 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json new file mode 100644 index 0000000..2526f21 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "color-secondary" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx new file mode 100644 index 0000000..fad2018 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx @@ -0,0 +1,7 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/input.tsx new file mode 100644 index 0000000..2beb102 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/input.tsx @@ -0,0 +1,7 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json new file mode 100644 index 0000000..29e0200 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx new file mode 100644 index 0000000..1e8eab4 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/input.tsx new file mode 100644 index 0000000..16bc1d1 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json new file mode 100644 index 0000000..29e0200 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/disabled-boolean/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx new file mode 100644 index 0000000..ced449c --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx @@ -0,0 +1,6 @@ + + +const MyComponent = ({ variant }: { variant: string }) => ( + {/* TODO(backstage-codemod): verify Button intent manually (dynamic-variant) */} + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/input.tsx new file mode 100644 index 0000000..1036aea --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = ({ variant }: { variant: string }) => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json new file mode 100644 index 0000000..2215f45 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "dynamic-variant" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..2e1df73 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Alert, Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..79f6fac --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Button from '@material-ui/core/Button'; +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..2c31eb8 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/merge-existing-bui/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "contained" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..01394fb --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..a03f340 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/input.tsx @@ -0,0 +1,5 @@ +import { Button } from '@material-ui/core'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..5dcb702 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "outlined" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx new file mode 100644 index 0000000..81a9ad7 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/input.tsx new file mode 100644 index 0000000..4cf3363 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json new file mode 100644 index 0000000..7e7e452 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "default" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..7eb642c --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..7eb642c --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx new file mode 100644 index 0000000..ae5fde4 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/input.tsx new file mode 100644 index 0000000..181787b --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json new file mode 100644 index 0000000..5dcb702 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "outlined" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx new file mode 100644 index 0000000..b711f76 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/input.tsx new file mode 100644 index 0000000..c29040e --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = (props: any) => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json new file mode 100644 index 0000000..aedf738 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/spread-props/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "text" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx new file mode 100644 index 0000000..2141afe --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx @@ -0,0 +1,6 @@ + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify Button intent manually (startIcon) */} + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/input.tsx new file mode 100644 index 0000000..a8f6ea3 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json new file mode 100644 index 0000000..9c805c3 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "startIcon" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx new file mode 100644 index 0000000..ce5909f --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/input.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/input.tsx new file mode 100644 index 0000000..2e5959c --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/input.tsx @@ -0,0 +1,5 @@ +import Button from '@material-ui/core/Button'; + +const MyComponent = () => ( + +); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json new file mode 100644 index 0000000..aedf738 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-button-to-bui-button": [ + { + "cardinality": { + "action": "button-migrated", + "variant": "text" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tsconfig.json b/codemods/misc/migrate-mui-button-to-bui-button/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/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-button-to-bui-button/workflow.yaml b/codemods/misc/migrate-mui-button-to-bui-button/workflow.yaml new file mode 100644 index 0000000..4d76527 --- /dev/null +++ b/codemods/misc/migrate-mui-button-to-bui-button/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 Button with BUI Button' + 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-icon-button-to-button-icon/CHANGELOG.md b/codemods/misc/migrate-mui-icon-button-to-button-icon/CHANGELOG.md new file mode 100644 index 0000000..37354d3 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-icon-button-to-button-icon diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml b/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml new file mode 100644 index 0000000..bbbf6d4 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-icon-button-to-button-icon' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace IconButton with ButtonIcon' +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', 'icon', 'button', 'button', 'icon'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/package.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/package.json new file mode 100644 index 0000000..bdb4f0a --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-icon-button-to-button-icon", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace IconButton with ButtonIcon", + "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-icon-button-to-button-icon/scripts/codemod.ts b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts new file mode 100644 index 0000000..161178c --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts @@ -0,0 +1,303 @@ +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-icon-button-to-button-icon') + +const BUI_SOURCE = '@backstage/ui' + +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 +} + +function collectIconButtonImports(rootNode: SgNode): { + iconButtonLocalName: string | null + importNodesToRemove: SgNode[] +} { + let iconButtonLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + + // Default import: import IconButton from '@material-ui/core/IconButton' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/IconButton')) { + iconButtonLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + // Named import from barrel: import { IconButton } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'IconButton') + if (localName) { + iconButtonLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } + } + } + + return { iconButtonLocalName, importNodesToRemove } +} + +function addButtonIconToBuiImport(rootNode: SgNode, edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + let hasButtonIcon = false + for (const spec of specifiers) { + const idents = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + if (idents[0]?.text() === 'ButtonIcon') { + hasButtonIcon = true + } + } + if (!hasButtonIcon) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + names.push('ButtonIcon') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } + } 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 { ButtonIcon } 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 getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text') { + if (child.text().trim().length === 0) { + continue + } + } + children.push(child) + } + return children +} + +function isSingleIconChild(child: SgNode): boolean { + const kind = child.kind() + return kind === 'jsx_self_closing_element' || kind === 'jsx_element' || kind === 'jsx_expression' +} + +/** Props that are dropped silently (MUI-specific, no BUI equivalent). */ +const DROPPED_PROPS = new Set(['size', 'edge', 'color', 'disableRipple', 'disableFocusRipple']) + +function transformIconButtonElements(rootNode: SgNode, iconButtonLocalName: string, edits: Edit[]): void { + 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 !== iconButtonLocalName) { + continue + } + + // Self-closing IconButton has no icon child — TODO + if (isSelfClosing) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'no-children' }) + continue + } + + // Need exactly one icon child + const children = getJsxChildren(el) + if (children.length !== 1 || !isSingleIconChild(children[0]!)) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-children' }) + continue + } + + const iconChild = children[0]! + const iconText = iconChild.text() + + // Check for aria-label — required for accessibility + const hasAriaLabel = hasProp(opening, 'aria-label') + if (!hasAriaLabel) { + edits.push(el.replace(`{/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'missing-aria-label' }) + continue + } + + // Build new props + const newProps: string[] = [] + + // icon prop from child + newProps.push(`icon={${iconText}}`) + + // Map props from opening element + 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 (propName === 'disabled') { + // Map disabled → isDisabled + const exprNode = attr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + newProps.push(`isDisabled=${exprNode.text()}`) + } else { + newProps.push('isDisabled') + } + continue + } + + if (propName === 'onClick') { + // Map onClick → onPress + const exprNode = attr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + newProps.push(`onPress=${exprNode.text()}`) + } + continue + } + + if (DROPPED_PROPS.has(propName)) { + migrationMetric.increment({ action: 'prop-dropped', prop: propName }) + continue + } + + // Preserve all other props (aria-label, className, data-*, etc.) + 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(' ')}` : '' + edits.push(el.replace(``)) + migrationMetric.increment({ action: 'icon-button-migrated' }) + } +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { iconButtonLocalName, importNodesToRemove } = collectIconButtonImports(rootNode) + + if (!iconButtonLocalName) { + return null + } + + // Remove MUI imports + for (const imp of importNodesToRemove) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + // Add BUI import + addButtonIconToBuiImport(rootNode, edits) + + // Transform JSX elements + transformIconButtonElements(rootNode, iconButtonLocalName, edits) + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx new file mode 100644 index 0000000..eb46ea4 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + } aria-label="delete" isDisabled={!canDelete} onPress={handleDelete} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/input.tsx new file mode 100644 index 0000000..0f099bb --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx new file mode 100644 index 0000000..99b4b1d --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + } aria-label="copy" className="custom-btn" data-testid="copy-btn" onPress={handleCopy} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/input.tsx new file mode 100644 index 0000000..d1c2a3e --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx new file mode 100644 index 0000000..2147d12 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx @@ -0,0 +1,9 @@ + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */} + + + extra + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/input.tsx new file mode 100644 index 0000000..c82139c --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/input.tsx @@ -0,0 +1,8 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + extra + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json new file mode 100644 index 0000000..2ce4979 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "complex-children" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx new file mode 100644 index 0000000..1997335 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + } aria-label="close" isDisabled onPress={onClose} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/input.tsx new file mode 100644 index 0000000..c64057e --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx new file mode 100644 index 0000000..c9c3eba --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + } aria-label="menu" onPress={handleMenu} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/input.tsx new file mode 100644 index 0000000..eb387ea --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json new file mode 100644 index 0000000..f38e617 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/metrics.json @@ -0,0 +1,43 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "color" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "edge" + }, + "count": 1 + }, + { + "cardinality": { + "action": "prop-dropped", + "prop": "size" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..9ed4995 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + } aria-label="close" onPress={handleClose} /> + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..3f21bf4 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/input.tsx @@ -0,0 +1,11 @@ +import IconButton from '@material-ui/core/IconButton'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..43d525f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/merge-existing-bui/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx new file mode 100644 index 0000000..ad71cac --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx @@ -0,0 +1,8 @@ + + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */} + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/input.tsx new file mode 100644 index 0000000..b500ecf --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json new file mode 100644 index 0000000..76be6b0 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "missing-aria-label" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..aaf6ba8 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = () => ( + } aria-label="edit" onPress={handleEdit} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..4e371e1 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/input.tsx @@ -0,0 +1,7 @@ +import { IconButton } from '@material-ui/core'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..d27199f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } aria-label="delete" onPress={handleDelete} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..d27199f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { ButtonIcon } from '@backstage/ui'; + +const MyComponent = () => ( + } aria-label="delete" onPress={handleDelete} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx new file mode 100644 index 0000000..bc06025 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx @@ -0,0 +1,5 @@ + + +const MyComponent = (props: any) => ( + } aria-label="action" {...props} /> +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/input.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/input.tsx new file mode 100644 index 0000000..aee1ce8 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/input.tsx @@ -0,0 +1,7 @@ +import IconButton from '@material-ui/core/IconButton'; + +const MyComponent = (props: any) => ( + + + +); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json new file mode 100644 index 0000000..aa36217 --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-icon-button-to-button-icon": [ + { + "cardinality": { + "action": "icon-button-migrated" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tsconfig.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/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-icon-button-to-button-icon/workflow.yaml b/codemods/misc/migrate-mui-icon-button-to-button-icon/workflow.yaml new file mode 100644 index 0000000..ac5b58b --- /dev/null +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/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 IconButton with ButtonIcon' + 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-tooltip-to-bui-tooltip/CHANGELOG.md b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md new file mode 100644 index 0000000..c167960 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-tooltip-to-bui-tooltip diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml new file mode 100644 index 0000000..26d8628 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-tooltip-to-bui-tooltip' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Tooltip with TooltipTrigger' +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', 'tooltip', 'bui', 'tooltip'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/package.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/package.json new file mode 100644 index 0000000..12cad93 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-tooltip-to-bui-tooltip", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Tooltip with TooltipTrigger", + "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-tooltip-to-bui-tooltip/scripts/codemod.ts b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts new file mode 100644 index 0000000..d19f5b6 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts @@ -0,0 +1,401 @@ +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-tooltip-to-bui-tooltip') + +const BUI_SOURCE = '@backstage/ui' + +const TODO_PROPS = new Set([ + 'leaveDelay', + 'enterDelay', + 'enterTouchDelay', + 'leaveTouchDelay', + 'interactive', + 'TransitionComponent', + 'TransitionProps', + 'PopperProps', + '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 +} + +function collectTooltipImports(rootNode: SgNode): { + tooltipLocalName: string | null + importNodesToRemove: SgNode[] +} { + let tooltipLocalName: string | null = null + const importNodesToRemove: SgNode[] = [] + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Tooltip')) { + tooltipLocalName = getDefaultImportName(imp) + importNodesToRemove.push(imp) + } + + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + const localName = getNamedImportLocalName(imp, 'Tooltip') + if (localName) { + tooltipLocalName = localName + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (allSpecifiers.length <= 1) { + importNodesToRemove.push(imp) + } + } + } + + return { tooltipLocalName, importNodesToRemove } +} + +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 hasProp(opening: SgNode, propName: string): boolean { + return ( + opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) !== 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 isPropDynamic(opening: SgNode, propName: string): boolean { + const attr = getPropAttr(opening, propName) + if (!attr) { + return false + } + return attr.find({ rule: { kind: 'jsx_expression' } }) !== 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 getSimpleHandlerFromProp(opening: SgNode, propName: string): string | null { + const attr = getPropAttr(opening, propName) + if (!attr) { + return null + } + const expr = attr.find({ rule: { kind: 'jsx_expression' } }) + if (!expr) { + return null + } + const children: SgNode[] = [] + for (const child of expr.children()) { + if (child.kind() !== '{' && child.kind() !== '}') { + children.push(child) + } + } + if (children.length === 1 && children[0]!.is('identifier')) { + return children[0].text() + } + return null +} + +function buildControlledTooltipProps(opening: SgNode): string[] { + const props: string[] = [] + + const openValue = getPropRawValue(opening, 'open') + if (openValue) { + props.push(`isOpen=${openValue}`) + } + + const closeHandler = getSimpleHandlerFromProp(opening, 'onClose') + const openHandler = getSimpleHandlerFromProp(opening, 'onOpen') + + if (closeHandler) { + props.push(`onOpenChange={open => !open && ${closeHandler}()}`) + migrationMetric.increment({ action: 'onClose-rewritten' }) + } else if (openHandler) { + props.push(`onOpenChange={open => open && ${openHandler}()}`) + migrationMetric.increment({ action: 'onOpen-rewritten' }) + } else if (hasProp(opening, 'onClose') || hasProp(opening, 'onOpen')) { + return [] + } + + return props +} + +function getJsxChildren(element: SgNode): SgNode[] { + const children: SgNode[] = [] + for (const child of element.children()) { + const kind = child.kind() + if (kind === 'jsx_opening_element' || kind === 'jsx_closing_element') { + continue + } + if (kind === 'jsx_text') { + if (child.text().trim().length === 0) { + continue + } + } + children.push(child) + } + return children +} + +function transformTooltipElements(rootNode: SgNode, tooltipLocalName: string, edits: Edit[]): boolean { + let migrated = false + 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 !== tooltipLocalName) { + continue + } + + let needsTodo = false + for (const prop of TODO_PROPS) { + if (hasProp(opening, prop)) { + needsTodo = true + break + } + } + + const controlledProps = buildControlledTooltipProps(opening) + if ( + (hasProp(opening, 'onClose') || hasProp(opening, 'onOpen')) && + controlledProps.length === 0 && + !getPropRawValue(opening, 'open') + ) { + needsTodo = true + } else if ( + (hasProp(opening, 'onClose') || hasProp(opening, 'onOpen')) && + controlledProps.length === 0 && + getPropRawValue(opening, 'open') + ) { + needsTodo = true + } + + if (needsTodo) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) + continue + } + + const titleStr = getPropStringValue(opening, 'title') + const titleDynamic = isPropDynamic(opening, 'title') + const titleRaw = getPropRawValue(opening, 'title') + + if (!titleStr && !titleDynamic) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'no-title' }) + continue + } + + if (isSelfClosing) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'no-children' }) + continue + } + + const children = getJsxChildren(el) + if (children.length !== 1) { + edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'multiple-children' }) + continue + } + + const child = children[0]! + const childText = child.text() + + let tooltipContent: string + if (titleStr !== null) { + tooltipContent = titleStr + } else if (titleRaw !== null) { + tooltipContent = titleRaw + } else { + tooltipContent = '' + } + + const placementValue = getPropStringValue(opening, 'placement') + const placementDynamic = isPropDynamic(opening, 'placement') + let placementTodo = '' + if (placementValue || placementDynamic) { + placementTodo = '{/* TODO(backstage-codemod): verify Tooltip placement mapping manually */}\n' + migrationMetric.increment({ action: 'placement-dropped', value: placementValue ?? 'dynamic' }) + } + + const tooltipEl = `${tooltipContent}` + const triggerProps = controlledProps.length > 0 ? ` ${controlledProps.join(' ')}` : '' + + edits.push( + el.replace(`${placementTodo}\n ${childText}\n ${tooltipEl}\n`), + ) + migrationMetric.increment({ action: 'tooltip-migrated' }) + migrated = true + } + + return migrated +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { tooltipLocalName, importNodesToRemove } = collectTooltipImports(rootNode) + + if (!tooltipLocalName) { + return null + } + + const migrated = transformTooltipElements(rootNode, tooltipLocalName, edits) + + if (!migrated) { + return edits.length > 0 ? rootNode.commitEdits(edits) : null + } + + const buiNames = ['Tooltip', 'TooltipTrigger'] + const existingBui = findImportStatementsFrom(rootNode, BUI_SOURCE) + + if (existingBui.length === 0 && importNodesToRemove.length === 1) { + edits.push(importNodesToRemove[0]!.replace(`import { ${buiNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + migrationMetric.increment({ action: 'import-removed' }) + } else { + addBuiImport(rootNode, buiNames, edits) + 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-tooltip-to-bui-tooltip/tests/controlled-todo/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/expected.tsx new file mode 100644 index 0000000..c602041 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/expected.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + !open && handleClose()}> + Hover me + Info + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/input.tsx new file mode 100644 index 0000000..ce5035e --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/input.tsx @@ -0,0 +1,7 @@ +import Tooltip from '@material-ui/core/Tooltip'; + +const MyComponent = () => ( + + Hover me + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/metrics.json new file mode 100644 index 0000000..f5c0ac6 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/controlled-todo/metrics.json @@ -0,0 +1,28 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "onClose-rewritten" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/expected.tsx new file mode 100644 index 0000000..b18f1ae --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/expected.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = ({ label }: { label: string }) => ( + + {label} + {label} + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/input.tsx new file mode 100644 index 0000000..ab072ed --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/input.tsx @@ -0,0 +1,7 @@ +import Tooltip from '@material-ui/core/Tooltip'; + +const MyComponent = ({ label }: { label: string }) => ( + + {label} + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/metrics.json new file mode 100644 index 0000000..d22f925 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/dynamic-title/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..3d1ddb9 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + + Save changes + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..013dfd2 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/input.tsx @@ -0,0 +1,8 @@ +import Tooltip from '@material-ui/core/Tooltip'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..9fd7f1b --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/merge-existing-bui/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..0642311 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/expected.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + + Help + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..01ce08c --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/input.tsx @@ -0,0 +1,7 @@ +import { Tooltip } from '@material-ui/core'; + +const MyComponent = () => ( + + + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..d22f925 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/named-barrel-import/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..f78bc63 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/expected.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + Hover + Info + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..f78bc63 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/noop-no-import/input.tsx @@ -0,0 +1,8 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + Hover + Info + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/expected.tsx new file mode 100644 index 0000000..5ad3080 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/expected.tsx @@ -0,0 +1,9 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify Tooltip placement mapping manually */} + + Hover + Info + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/input.tsx new file mode 100644 index 0000000..c4155c2 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/input.tsx @@ -0,0 +1,7 @@ +import Tooltip from '@material-ui/core/Tooltip'; + +const MyComponent = () => ( + + Hover + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/metrics.json new file mode 100644 index 0000000..ea9ce79 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/placement-dropped/metrics.json @@ -0,0 +1,29 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "placement-dropped", + "value": "top" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/expected.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/expected.tsx new file mode 100644 index 0000000..07f66b1 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/expected.tsx @@ -0,0 +1,10 @@ +import { Tooltip, TooltipTrigger } from '@backstage/ui'; + +const MyComponent = () => ( + + + + + More actions + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/input.tsx b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/input.tsx new file mode 100644 index 0000000..b2f6fc3 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/input.tsx @@ -0,0 +1,9 @@ +import Tooltip from '@material-ui/core/Tooltip'; + +const MyComponent = () => ( + + + + + +); diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/metrics.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/metrics.json new file mode 100644 index 0000000..d22f925 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tests/simple-wrapper/metrics.json @@ -0,0 +1,22 @@ +{ + "migrate-mui-tooltip-to-bui-tooltip": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "tooltip-migrated" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tsconfig.json b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/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-tooltip-to-bui-tooltip/workflow.yaml b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/workflow.yaml new file mode 100644 index 0000000..2c6f782 --- /dev/null +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/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 Tooltip with TooltipTrigger' + 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-typography-to-text/CHANGELOG.md b/codemods/misc/migrate-mui-typography-to-text/CHANGELOG.md new file mode 100644 index 0000000..ed60226 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/migrate-mui-typography-to-text diff --git a/codemods/misc/migrate-mui-typography-to-text/codemod.yaml b/codemods/misc/migrate-mui-typography-to-text/codemod.yaml new file mode 100644 index 0000000..80104b5 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/codemod.yaml @@ -0,0 +1,20 @@ +schema_version: '1.0' + +name: '@backstage/migrate-mui-typography-to-text' +version: '0.1.0' +description: 'MUI 4 to BUI: Replace Typography with Text' +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', 'typography', 'text'] + +registry: + access: 'public' + visibility: 'public' + +capabilities: [] diff --git a/codemods/misc/migrate-mui-typography-to-text/package.json b/codemods/misc/migrate-mui-typography-to-text/package.json new file mode 100644 index 0000000..e5940aa --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/package.json @@ -0,0 +1,13 @@ +{ + "name": "@backstage/migrate-mui-typography-to-text", + "version": "0.1.0", + "description": "MUI 4 to BUI: Replace Typography with Text", + "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-typography-to-text/scripts/codemod.ts b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts new file mode 100644 index 0000000..452cbd8 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -0,0 +1,465 @@ +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-typography-to-text') + +// MUI Typography variant → BUI Text variant +const VARIANT_MAP: Record = { + h1: 'title-large', + h2: 'title-small', + h3: 'subtitle', + h4: 'body-small', + h5: 'caption', + h6: 'caption', + subtitle1: 'subtitle', + subtitle2: 'subtitle', + body1: 'body', + body2: 'body-small', + caption: 'caption', + overline: 'caption', + button: 'body', +} + +// MUI color prop → BUI Text color prop +const COLOR_MAP: Record = { + textPrimary: 'primary', + textSecondary: 'secondary', + primary: 'primary', + secondary: 'secondary', + error: 'danger', + inherit: 'inherit', +} + +const BUI_SOURCE = '@backstage/ui' + +// Component names we target +const TARGET_COMPONENTS = new Set(['Typography', 'DialogContentText']) + +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 +} + +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + +interface ImportCollectionResult { + localNames: Map + importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, string[]> +} + +function collectTypographyImports(rootNode: SgNode): ImportCollectionResult { + const localNames = new Map() + const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, string[]>() + + // Default import: import Typography from '@material-ui/core/Typography' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Typography')) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, 'Typography') + } + importNodesToRemove.push(imp) + } + + // Default import: import DialogContentText from '@material-ui/core/DialogContentText' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/DialogContentText')) { + const name = getDefaultImportName(imp) + if (name) { + localNames.set(name, 'DialogContentText') + } + importNodesToRemove.push(imp) + } + + // Named imports from barrel: import { Typography, DialogContentText } from '@material-ui/core' + for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core')) { + let foundCount = 0 + + for (const componentName of TARGET_COMPONENTS) { + const localName = getNamedImportLocalName(imp, componentName) + if (localName) { + localNames.set(localName, componentName) + foundCount++ + } + } + + if (foundCount > 0) { + const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) + if (foundCount >= allSpecifiers.length) { + importNodesToRemove.push(imp) + } else { + const toRemove: string[] = [] + for (const componentName of TARGET_COMPONENTS) { + if (getNamedImportLocalName(imp, componentName)) { + toRemove.push(componentName) + } + } + importSpecifiersToRemove.set(imp, toRemove) + } + } + } + + return { localNames, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers( + imp: SgNode, + namesToRemove: string[], + edits: Edit[], + appendTextImport = false, +): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + let replacement = `import { ${specTexts} } from '@material-ui/core';` + if (appendTextImport) { + replacement += `\nimport { Text } from '${BUI_SOURCE}';` + migrationMetric.increment({ action: 'import-added' }) + } + edits.push(imp.replace(replacement)) + } + migrationMetric.increment({ action: 'import-removed' }) +} + +function addTextToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): void { + const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) + const existingImport = existingImports[0] ?? null + + if (existingImport) { + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + let hasText = false + for (const spec of specifiers) { + const idents = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + if (idents[0]?.text() === 'Text') { + hasText = true + } + } + if (!hasText) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const text = namedImports.text() + const inner = text.slice(1, -1).trim() + const names = inner + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + names.push('Text') + names.sort() + edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } + } + return + } + + const removeIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removeIds.has(imp.id())) ?? null + + if (importNodesToRemove.length === 1 && !anchorImport) { + edits.push(importNodesToRemove[0]!.replace(`import { Text } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-removed' }) + } else { + const firstImport = allImports[0] + if (firstImport) { + edits.push(firstImport.replace(`import { Text } from '${BUI_SOURCE}';\n${firstImport.text()}`)) + } else if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Text } from '${BUI_SOURCE}';`)) + } + } + + migrationMetric.increment({ action: 'import-added' }) +} + +function getAttrStringValue( + opening: SgNode, + propName: string, +): { value: string | null; isDynamic: boolean; attrNode: SgNode | null } { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(propName)}$`, + }, + }, + }) + + if (!attr) { + return { value: null, isDynamic: false, attrNode: null } + } + + const stringNode = attr.find({ rule: { kind: 'string' } }) + if (stringNode) { + const frag = stringNode.find({ rule: { kind: 'string_fragment' } }) + return { value: frag?.text() ?? null, isDynamic: false, attrNode: attr } + } + + const exprNode = attr.find({ rule: { kind: 'jsx_expression' } }) + if (exprNode) { + return { value: exprNode.text(), isDynamic: true, attrNode: attr } + } + + // Boolean attribute (e.g. gutterBottom without value) + return { value: '', isDynamic: false, attrNode: attr } +} + +function getOpeningElement(el: SgNode): SgNode | null { + if (el.is('jsx_self_closing_element')) { + return el + } + return el.child(0) +} + +function getElementName(opening: SgNode): string | null { + // The tag name is the first named child that is an identifier + for (const child of opening.children()) { + if (child.is('identifier') || child.is('member_expression')) { + return child.text() + } + } + return null +} + +function transformTypographyElements( + rootNode: SgNode, + localNames: Map, + edits: Edit[], + preservedLocalNames: Set, +): boolean { + let migrated = false + 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 = getOpeningElement(el) + if (!opening) { + continue + } + + const componentLocalName = getElementName(opening) + if (!componentLocalName || !localNames.has(componentLocalName)) { + continue + } + + // Collect props + const { value: variantValue, isDynamic: variantDynamic } = getAttrStringValue(opening, 'variant') + const { value: colorValue, isDynamic: colorDynamic } = getAttrStringValue(opening, 'color') + const { value: componentValue, isDynamic: componentDynamic } = getAttrStringValue(opening, 'component') + const { attrNode: gutterBottomAttr } = getAttrStringValue(opening, 'gutterBottom') + + // Check for unmappable dynamic values + if (variantDynamic || colorDynamic) { + preservedLocalNames.add(componentLocalName) + edits.push(el.replace(`{/* TODO(backstage-codemod): verify Text variant manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-props' }) + continue + } + + // Map variant + let buiVariant: string | null = null + let needsTodo = false + if (variantValue) { + buiVariant = VARIANT_MAP[variantValue] ?? null + if (!buiVariant) { + needsTodo = true + } + } + + // Map color + let buiColor: string | null = null + if (colorValue) { + buiColor = COLOR_MAP[colorValue] ?? null + if (!buiColor) { + needsTodo = true + } + } + + if (needsTodo) { + preservedLocalNames.add(componentLocalName) + edits.push(el.replace(`{/* TODO(backstage-codemod): verify Text variant manually */}\n${el.text()}`)) + migrationMetric.increment({ action: 'todo-inserted', reason: 'unmapped-variant-or-color' }) + continue + } + + // Build new props + const newProps: string[] = [] + if (buiVariant) { + newProps.push(`variant="${buiVariant}"`) + } + if (buiColor) { + newProps.push(`color="${buiColor}"`) + } + // Convert component → as + if (componentValue && !componentDynamic) { + newProps.push(`as="${componentValue}"`) + } else if (componentDynamic && componentValue) { + newProps.push(`as={${componentValue.slice(1, -1)}}`) + } + + // Collect remaining props we haven't handled + const handledProps = new Set(['variant', 'color', 'component', 'gutterBottom']) + 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 + } + // Preserve any unhandled prop as-is + newProps.push(attr.text()) + } + + // Also 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(' ')}` : '' + + if (isSelfClosing) { + edits.push(el.replace(``)) + } else { + // Preserve children via AST traversal + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + + edits.push(el.replace(`${children}`)) + } + + if (gutterBottomAttr) { + migrationMetric.increment({ action: 'gutterBottom-dropped' }) + } + migrated = true + migrationMetric.increment({ + action: 'typography-migrated', + component: localNames.get(componentLocalName) ?? componentLocalName, + }) + } + + return migrated +} + +const transform: Codemod = async (root) => { + const rootNode = root.root() + const edits: Edit[] = [] + + const { localNames, importNodesToRemove, importSpecifiersToRemove } = collectTypographyImports(rootNode) + + if (localNames.size === 0) { + return null + } + + const preservedLocalNames = new Set() + const migrated = transformTypographyElements(rootNode, localNames, edits, preservedLocalNames) + + for (const imp of importNodesToRemove) { + const defaultName = getDefaultImportName(imp) + if (defaultName && preservedLocalNames.has(defaultName)) { + continue + } + if ( + migrated && + importNodesToRemove.length === 1 && + findImportStatementsFrom(rootNode, BUI_SOURCE).length === 0 && + imp.id() === importNodesToRemove[0]?.id() + ) { + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + + let addedTextViaBarrelPrune = false + + for (const [imp, namesToRemove] of importSpecifiersToRemove) { + const removableNames = namesToRemove.filter((componentName) => { + const localName = getNamedImportLocalName(imp, componentName) + return localName === null || !preservedLocalNames.has(localName) + }) + if (removableNames.length === 0) { + continue + } + const appendTextImport = migrated && findImportStatementsFrom(rootNode, BUI_SOURCE).length === 0 + if (appendTextImport) { + addedTextViaBarrelPrune = true + } + pruneBarrelImportSpecifiers(imp, removableNames, edits, appendTextImport) + } + + if (migrated && !addedTextViaBarrelPrune) { + addTextToBuiImport(rootNode, importNodesToRemove, edits) + } + + return edits.length > 0 ? rootNode.commitEdits(edits) : null +} + +export default transform diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx new file mode 100644 index 0000000..5070b94 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Details +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/input.tsx new file mode 100644 index 0000000..b3fc5af --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Details +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/basic-body-text/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx new file mode 100644 index 0000000..8f07c9d --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Inline text +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/input.tsx new file mode 100644 index 0000000..7ab325f --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Inline text +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/component-to-as/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx new file mode 100644 index 0000000..ec88b26 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyDialog = () => ( + Overview +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/input.tsx new file mode 100644 index 0000000..3088e48 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/input.tsx @@ -0,0 +1,5 @@ +import DialogContentText from '@material-ui/core/DialogContentText'; + +const MyDialog = () => ( + Overview +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json new file mode 100644 index 0000000..37cd4fb --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/dialog-content-text/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "DialogContentText" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx new file mode 100644 index 0000000..6fe8eb6 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Label +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/input.tsx new file mode 100644 index 0000000..e456817 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Label +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/extra-props-preserved/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx new file mode 100644 index 0000000..f76f710 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Section Title +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/input.tsx new file mode 100644 index 0000000..1ddb8e6 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Section Title +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json new file mode 100644 index 0000000..dab4181 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json @@ -0,0 +1,29 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "gutterBottom-dropped" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx new file mode 100644 index 0000000..e3ccbb6 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const PageTitle = () => ( + Welcome +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/input.tsx new file mode 100644 index 0000000..42bf382 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const PageTitle = () => ( + Welcome +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/heading-variant/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx new file mode 100644 index 0000000..d37b4fe --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/expected.tsx @@ -0,0 +1,9 @@ + +import { Button, Text } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Small text + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/input.tsx new file mode 100644 index 0000000..2ff0cd2 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/input.tsx @@ -0,0 +1,9 @@ +import Typography from '@material-ui/core/Typography'; +import { Button } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + Small text + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json new file mode 100644 index 0000000..83d6a25 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/merge-existing-bui/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-merged" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx new file mode 100644 index 0000000..78bc5c1 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/expected.tsx @@ -0,0 +1,9 @@ +import { Button } from '@material-ui/core'; +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + <> + Hello + + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/input.tsx new file mode 100644 index 0000000..fff07d1 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { Typography, Button } from '@material-ui/core'; + +const MyComponent = () => ( + <> + Hello + + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/named-barrel-import/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx new file mode 100644 index 0000000..ffcabf4 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx new file mode 100644 index 0000000..ffcabf4 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-import/input.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/expected.tsx new file mode 100644 index 0000000..ffcabf4 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/expected.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/input.tsx new file mode 100644 index 0000000..ffcabf4 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/input.tsx @@ -0,0 +1,5 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + Already migrated +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx new file mode 100644 index 0000000..4d16d58 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/expected.tsx @@ -0,0 +1,7 @@ +import { Text } from '@backstage/ui'; + +const MyComponent = () => ( + + Click here for details. + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/input.tsx new file mode 100644 index 0000000..59d9719 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/input.tsx @@ -0,0 +1,7 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + + Click here for details. + +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json new file mode 100644 index 0000000..df5928a --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/preserve-children/metrics.json @@ -0,0 +1,23 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx new file mode 100644 index 0000000..7efd38c --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx @@ -0,0 +1,6 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + {/* TODO(backstage-codemod): verify Text variant manually */} +Hidden text +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/input.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/input.tsx new file mode 100644 index 0000000..57b1642 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/input.tsx @@ -0,0 +1,5 @@ +import Typography from '@material-ui/core/Typography'; + +const MyComponent = () => ( + Hidden text +); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json new file mode 100644 index 0000000..bc94600 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json @@ -0,0 +1,11 @@ +{ + "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "todo-inserted", + "reason": "unmapped-variant-or-color" + }, + "count": 1 + } + ] +} \ No newline at end of file diff --git a/codemods/misc/migrate-mui-typography-to-text/tsconfig.json b/codemods/misc/migrate-mui-typography-to-text/tsconfig.json new file mode 100644 index 0000000..5ad4d6f --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/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-typography-to-text/workflow.yaml b/codemods/misc/migrate-mui-typography-to-text/workflow.yaml new file mode 100644 index 0000000..ee784f3 --- /dev/null +++ b/codemods/misc/migrate-mui-typography-to-text/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 Typography with Text' + 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..c8b4823 100644 --- a/yarn.lock +++ b/yarn.lock @@ -172,6 +172,51 @@ __metadata: languageName: unknown linkType: soft +"@backstage/migrate-mui-alert-to-bui-alert@workspace:codemods/misc/migrate-mui-alert-to-bui-alert": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-alert-to-bui-alert@workspace:codemods/misc/migrate-mui-alert-to-bui-alert" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-button-to-bui-button@workspace:codemods/misc/migrate-mui-button-to-bui-button": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-button-to-bui-button@workspace:codemods/misc/migrate-mui-button-to-bui-button" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-icon-button-to-button-icon@workspace:codemods/misc/migrate-mui-icon-button-to-button-icon": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-icon-button-to-button-icon@workspace:codemods/misc/migrate-mui-icon-button-to-button-icon" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-tooltip-to-bui-tooltip@workspace:codemods/misc/migrate-mui-tooltip-to-bui-tooltip": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-tooltip-to-bui-tooltip@workspace:codemods/misc/migrate-mui-tooltip-to-bui-tooltip" + dependencies: + "@codemod.com/jssg-types": "npm:1.6.2" + codemod: "npm:1.12.3" + languageName: unknown + linkType: soft + +"@backstage/migrate-mui-typography-to-text@workspace:codemods/misc/migrate-mui-typography-to-text": + version: 0.0.0-use.local + resolution: "@backstage/migrate-mui-typography-to-text@workspace:codemods/misc/migrate-mui-typography-to-text" + 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 0acdb48216042fbc78bc65859ae77ae7a0f05933 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:23:14 -0500 Subject: [PATCH 2/6] fix: resolve lint warnings in core MUI-to-BUI codemods Replace async transforms with Promise.resolve returns and remove non-null assertions to satisfy oxlint --deny-warnings in CI. Co-authored-by: Cursor --- .../scripts/codemod.ts | 6 ++--- .../scripts/codemod.ts | 6 ++--- .../scripts/codemod.ts | 10 +++---- .../scripts/codemod.ts | 27 ++++++++++++------- .../scripts/codemod.ts | 15 ++++++----- 5 files changed, 37 insertions(+), 27 deletions(-) diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts index 2c1d276..bc21f11 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts @@ -344,14 +344,14 @@ function transformAlertElements( } } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { alertLocalName, alertTitleLocalName, importNodesToRemove } = collectAlertImports(rootNode) if (!alertLocalName) { - return null + return Promise.resolve(null) } for (const imp of importNodesToRemove) { @@ -362,7 +362,7 @@ const transform: Codemod = async (root) => { buildBuiImportEdit(rootNode, edits) transformAlertElements(rootNode, alertLocalName, alertTitleLocalName, 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-button-to-bui-button/scripts/codemod.ts b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts index b399cc7..16be7a9 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts @@ -326,14 +326,14 @@ function transformButtonElements(rootNode: SgNode, buttonLocalName: string, } } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { buttonLocalName, importNodesToRemove } = collectButtonImports(rootNode) if (!buttonLocalName) { - return null + return Promise.resolve(null) } // Remove MUI imports @@ -348,7 +348,7 @@ const transform: Codemod = async (root) => { // Transform JSX elements transformButtonElements(rootNode, buttonLocalName, 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-icon-button-to-button-icon/scripts/codemod.ts b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts index 161178c..4c3729b 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts @@ -200,13 +200,13 @@ function transformIconButtonElements(rootNode: SgNode, iconButtonLocalName: // Need exactly one icon child const children = getJsxChildren(el) - if (children.length !== 1 || !isSingleIconChild(children[0]!)) { + const [iconChild] = children + if (children.length !== 1 || !iconChild || !isSingleIconChild(iconChild)) { edits.push(el.replace(`{/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */}\n${el.text()}`)) migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-children' }) continue } - const iconChild = children[0]! const iconText = iconChild.text() // Check for aria-label — required for accessibility @@ -275,14 +275,14 @@ function transformIconButtonElements(rootNode: SgNode, iconButtonLocalName: } } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { iconButtonLocalName, importNodesToRemove } = collectIconButtonImports(rootNode) if (!iconButtonLocalName) { - return null + return Promise.resolve(null) } // Remove MUI imports @@ -297,7 +297,7 @@ const transform: Codemod = async (root) => { // Transform JSX elements transformIconButtonElements(rootNode, iconButtonLocalName, 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-tooltip-to-bui-tooltip/scripts/codemod.ts b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts index d19f5b6..5b22da6 100644 --- a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts @@ -212,8 +212,9 @@ function getSimpleHandlerFromProp(opening: SgNode, propName: string): strin children.push(child) } } - if (children.length === 1 && children[0]!.is('identifier')) { - return children[0].text() + const [onlyChild] = children + if (children.length === 1 && onlyChild?.is('identifier')) { + return onlyChild.text() } return null } @@ -331,7 +332,10 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin continue } - const child = children[0]! + const [child] = children + if (!child) { + continue + } const childText = child.text() let tooltipContent: string @@ -364,29 +368,32 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin return migrated } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { tooltipLocalName, importNodesToRemove } = collectTooltipImports(rootNode) if (!tooltipLocalName) { - return null + return Promise.resolve(null) } const migrated = transformTooltipElements(rootNode, tooltipLocalName, edits) if (!migrated) { - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } const buiNames = ['Tooltip', 'TooltipTrigger'] const existingBui = findImportStatementsFrom(rootNode, BUI_SOURCE) if (existingBui.length === 0 && importNodesToRemove.length === 1) { - edits.push(importNodesToRemove[0]!.replace(`import { ${buiNames.join(', ')} } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) + const [importToReplace] = importNodesToRemove + if (importToReplace) { + edits.push(importToReplace.replace(`import { ${buiNames.join(', ')} } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + migrationMetric.increment({ action: 'import-removed' }) + } } else { addBuiImport(rootNode, buiNames, edits) for (const imp of importNodesToRemove) { @@ -395,7 +402,7 @@ const transform: Codemod = async (root) => { } } - 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-typography-to-text/scripts/codemod.ts b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts index 452cbd8..04da22a 100644 --- a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -212,10 +212,13 @@ function addTextToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode !removeIds.has(imp.id())) ?? null if (importNodesToRemove.length === 1 && !anchorImport) { - edits.push(importNodesToRemove[0]!.replace(`import { Text } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-removed' }) + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { Text } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-removed' }) + } } else { - const firstImport = allImports[0] + const [firstImport] = allImports if (firstImport) { edits.push(firstImport.replace(`import { Text } from '${BUI_SOURCE}';\n${firstImport.text()}`)) } else if (anchorImport) { @@ -408,14 +411,14 @@ function transformTypographyElements( return migrated } -const transform: Codemod = async (root) => { +const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] const { localNames, importNodesToRemove, importSpecifiersToRemove } = collectTypographyImports(rootNode) if (localNames.size === 0) { - return null + return Promise.resolve(null) } const preservedLocalNames = new Set() @@ -459,7 +462,7 @@ const transform: Codemod = async (root) => { addTextToBuiImport(rootNode, importNodesToRemove, edits) } - return edits.length > 0 ? rootNode.commitEdits(edits) : null + return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } export default transform From cbb4eec61477ca7529ce2ec326a3a5b2bb59b031 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:28:43 -0500 Subject: [PATCH 3/6] docs: regenerate README for core MUI-to-BUI codemods Co-authored-by: Cursor --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index b200fef..12268c4 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,16 @@ 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-alert-to-bui-alert](./codemods/misc/migrate-mui-alert-to-bui-alert) | MUI 4 to BUI: Replace MUI Alert with BUI Alert | +| [migrate-mui-button-to-bui-button](./codemods/misc/migrate-mui-button-to-bui-button) | MUI 4 to BUI: Replace MUI Button with BUI Button | +| [migrate-mui-icon-button-to-button-icon](./codemods/misc/migrate-mui-icon-button-to-button-icon) | MUI 4 to BUI: Replace IconButton with ButtonIcon | +| [migrate-mui-tooltip-to-bui-tooltip](./codemods/misc/migrate-mui-tooltip-to-bui-tooltip) | MUI 4 to BUI: Replace Tooltip with TooltipTrigger | +| [migrate-mui-typography-to-text](./codemods/misc/migrate-mui-typography-to-text) | MUI 4 to BUI: Replace Typography with Text | + ## Usage From 760fc95bfe1927c60c7babed12af11ecbfeb3204 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:51:28 -0500 Subject: [PATCH 4/6] fix: address Copilot review feedback on core component codemods Co-authored-by: Cursor --- .../scripts/codemod.ts | 94 +++++++++++++----- .../tests/core-import/expected.tsx | 2 +- .../tests/simple-warning/expected.tsx | 2 +- .../tests/with-title/expected.tsx | 3 +- .../scripts/codemod.ts | 89 +++++++++++++---- .../tests/color-primary-ok/expected.tsx | 2 +- .../tests/color-secondary-todo/expected.tsx | 6 +- .../tests/color-secondary-todo/metrics.json | 12 --- .../tests/contained-button/expected.tsx | 2 +- .../tests/disabled-boolean/expected.tsx | 2 +- .../tests/dynamic-variant-todo/expected.tsx | 6 +- .../tests/dynamic-variant-todo/metrics.json | 12 --- .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/no-variant/expected.tsx | 2 +- .../tests/outlined-button/expected.tsx | 2 +- .../tests/self-closing/expected.tsx | 2 +- .../tests/spread-props/expected.tsx | 2 +- .../tests/start-icon-todo/expected.tsx | 6 +- .../tests/start-icon-todo/metrics.json | 12 --- .../tests/text-button/expected.tsx | 2 +- .../scripts/codemod.ts | 97 +++++++++++++------ .../tests/basic-migration/expected.tsx | 2 +- .../tests/className-preserved/expected.tsx | 2 +- .../tests/complex-children-todo/expected.tsx | 6 +- .../tests/complex-children-todo/metrics.json | 12 --- .../tests/disabled-boolean/expected.tsx | 2 +- .../tests/dropped-props/expected.tsx | 2 +- .../missing-aria-label-todo/expected.tsx | 6 +- .../missing-aria-label-todo/metrics.json | 12 --- .../tests/named-barrel-import/expected.tsx | 2 +- .../tests/spread-props/expected.tsx | 2 +- .../codemod.yaml | 2 +- .../scripts/codemod.ts | 31 +++++- .../tests/gutter-bottom-dropped/expected.tsx | 3 + .../tests/gutter-bottom-dropped/metrics.json | 7 ++ .../tests/unmapped-variant-todo/expected.tsx | 4 +- 36 files changed, 284 insertions(+), 170 deletions(-) diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts index bc21f11..c6d8b58 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts @@ -117,7 +117,7 @@ function collectAlertImports(rootNode: SgNode): { return { alertLocalName, alertTitleLocalName, importNodesToRemove } } -function buildBuiImportEdit(rootNode: SgNode, edits: Edit[]): void { +function buildBuiImportEdit(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null @@ -147,16 +147,38 @@ function buildBuiImportEdit(rootNode: SgNode, edits: Edit[]): void { migrationMetric.increment({ action: 'import-merged' }) } } - } 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 { Alert } from '${BUI_SOURCE}';`)) - } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Alert } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { Alert } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { Alert } from '${BUI_SOURCE}';`)) } - migrationMetric.increment({ action: 'import-added' }) } + + migrationMetric.increment({ action: 'import-added' }) + return false +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` } function getSeverityValue(opening: SgNode): { value: string | null; isDynamic: boolean } { @@ -268,7 +290,9 @@ function transformAlertElements( alertLocalName: string, alertTitleLocalName: string | null, edits: Edit[], -): void { +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false const jsxElements = rootNode.findAll({ rule: { any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], @@ -287,21 +311,28 @@ function transformAlertElements( continue } - if (hasProp(opening, 'action') || hasProp(opening, 'onClose')) { + const insertTodo = (reason: string) => { + preserveImport = true edits.push( - el.replace(`{/* TODO(backstage-codemod): migrate Alert actions or complex children manually */}\n${el.text()}`), + el.replace( + withTodoComment( + '{/* TODO(backstage-codemod): migrate Alert actions or complex children manually */}', + el.text(), + ), + ), ) - migrationMetric.increment({ action: 'todo-inserted', reason: 'action-or-onClose' }) + migrationMetric.increment({ action: 'todo-inserted', reason }) + } + + if (hasProp(opening, 'action') || hasProp(opening, 'onClose')) { + insertTodo('action-or-onClose') continue } const { value: severityValue, isDynamic } = getSeverityValue(opening) if (isDynamic) { - edits.push( - el.replace(`{/* TODO(backstage-codemod): migrate Alert actions or complex children manually */}\n${el.text()}`), - ) - migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-severity' }) + insertTodo('dynamic-severity') continue } @@ -314,6 +345,7 @@ function transformAlertElements( } props.push('icon') edits.push(el.replace(``)) + migrated = true migrationMetric.increment({ action: 'alert-migrated', variant: 'self-closing' }) continue } @@ -321,10 +353,7 @@ function transformAlertElements( const { title, description, hasComplexContent } = extractChildContent(el, alertTitleLocalName) if (hasComplexContent) { - edits.push( - el.replace(`{/* TODO(backstage-codemod): migrate Alert actions or complex children manually */}\n${el.text()}`), - ) - migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-children' }) + insertTodo('complex-children') continue } @@ -340,8 +369,11 @@ function transformAlertElements( props.push(`description="${description}"`) } edits.push(el.replace(``)) + migrated = true migrationMetric.increment({ action: 'alert-migrated', variant: title ? 'with-title' : 'simple' }) } + + return { preserveImport, migrated } } const transform: Codemod = (root) => { @@ -354,13 +386,23 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) + const { preserveImport, migrated } = transformAlertElements(rootNode, alertLocalName, alertTitleLocalName, edits) + + let replacedImport = false + if (migrated) { + replacedImport = buildBuiImportEdit(rootNode, importNodesToRemove, edits) } - buildBuiImportEdit(rootNode, edits) - transformAlertElements(rootNode, alertLocalName, alertTitleLocalName, edits) + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx index 6a03291..793345b 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/core-import/expected.tsx @@ -1,4 +1,4 @@ - +import { Alert } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx index f885194..5c7e52e 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/simple-warning/expected.tsx @@ -1,4 +1,4 @@ - +import { Alert } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx index 34b8135..3e48901 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx @@ -1,5 +1,6 @@ - +import AlertTitle from '@material-ui/lab/AlertTitle'; +import { Alert } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts index 16be7a9..617eb74 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts @@ -98,7 +98,7 @@ function collectButtonImports(rootNode: SgNode): { return { buttonLocalName, importNodesToRemove } } -function addButtonToBuiImport(rootNode: SgNode, edits: Edit[]): void { +function addButtonToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null @@ -128,16 +128,38 @@ function addButtonToBuiImport(rootNode: SgNode, edits: Edit[]): void { migrationMetric.increment({ action: 'import-merged' }) } } - } 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 { Button } from '${BUI_SOURCE}';`)) - } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Button } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { Button } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { Button } from '${BUI_SOURCE}';`)) } - migrationMetric.increment({ action: 'import-added' }) } + + migrationMetric.increment({ action: 'import-added' }) + return false +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` } function getElementName(opening: SgNode): string | null { @@ -196,7 +218,13 @@ function isPropDynamic(opening: SgNode, propName: string): boolean { return attr.find({ rule: { kind: 'jsx_expression' } }) !== null } -function transformButtonElements(rootNode: SgNode, buttonLocalName: string, edits: Edit[]): void { +function transformButtonElements( + rootNode: SgNode, + buttonLocalName: string, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false const jsxElements = rootNode.findAll({ rule: { any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], @@ -244,9 +272,13 @@ function transformButtonElements(rootNode: SgNode, buttonLocalName: string, } if (needsTodo) { + preserveImport = true edits.push( el.replace( - `{/* TODO(backstage-codemod): verify Button intent manually (${todoReasons.join(', ')}) */}\n${el.text()}`, + withTodoComment( + `{/* TODO(backstage-codemod): verify Button intent manually (${todoReasons.join(', ')}) */}`, + el.text(), + ), ), ) migrationMetric.increment({ action: 'todo-inserted', reason: todoReasons.join(', ') }) @@ -264,8 +296,14 @@ function transformButtonElements(rootNode: SgNode, buttonLocalName: string, newProps.push(`variant="${buiVariant}"`) } else { // Unknown static variant — keep as-is with TODO + preserveImport = true edits.push( - el.replace(`{/* TODO(backstage-codemod): verify Button intent manually (unknown-variant) */}\n${el.text()}`), + el.replace( + withTodoComment( + '{/* TODO(backstage-codemod): verify Button intent manually (unknown-variant) */}', + el.text(), + ), + ), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'unknown-variant' }) continue @@ -322,8 +360,11 @@ function transformButtonElements(rootNode: SgNode, buttonLocalName: string, edits.push(el.replace(`${children}`)) } + migrated = true migrationMetric.increment({ action: 'button-migrated', variant: variantValue ?? 'default' }) } + + return { preserveImport, migrated } } const transform: Codemod = (root) => { @@ -336,17 +377,23 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - // Remove MUI imports - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) - } + const { preserveImport, migrated } = transformButtonElements(rootNode, buttonLocalName, edits) - // Add BUI import - addButtonToBuiImport(rootNode, edits) + let replacedImport = false + if (migrated) { + replacedImport = addButtonToBuiImport(rootNode, importNodesToRemove, edits) + } - // Transform JSX elements - transformButtonElements(rootNode, buttonLocalName, edits) + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx index 5dbc899..69d5b80 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-primary-ok/expected.tsx @@ -1,4 +1,4 @@ - +import { Button } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx index bd8a176..c27b952 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/expected.tsx @@ -1,6 +1,8 @@ - +import Button from '@material-ui/core/Button'; const MyComponent = () => ( + <> {/* TODO(backstage-codemod): verify Button intent manually (color-secondary) */} - + + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json index 2526f21..c2578da 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/color-secondary-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-button-to-bui-button": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 1 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx index fad2018..314bb3a 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/contained-button/expected.tsx @@ -1,4 +1,4 @@ - +import { Button } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx index ced449c..70c0676 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/expected.tsx @@ -1,6 +1,8 @@ - +import Button from '@material-ui/core/Button'; const MyComponent = ({ variant }: { variant: string }) => ( + <> {/* TODO(backstage-codemod): verify Button intent manually (dynamic-variant) */} - + + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json index 2215f45..0db75b6 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/dynamic-variant-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-button-to-bui-button": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 1 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx index 01394fb..2decf0d 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/named-barrel-import/expected.tsx @@ -1,4 +1,4 @@ - +import { Button } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx index 81a9ad7..cf010ef 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/no-variant/expected.tsx @@ -1,4 +1,4 @@ - +import { Button } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx index ae5fde4..16c64c0 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/outlined-button/expected.tsx @@ -1,4 +1,4 @@ - +import { Button } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx index b711f76..209b8f4 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/self-closing/expected.tsx @@ -1,4 +1,4 @@ - +import { Button } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx index 2141afe..bb97928 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/expected.tsx @@ -1,6 +1,8 @@ - +import Button from '@material-ui/core/Button'; const MyComponent = () => ( + <> {/* TODO(backstage-codemod): verify Button intent manually (startIcon) */} - + + ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json index 9c805c3..00f029c 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/start-icon-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-button-to-bui-button": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 1 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx index ce5909f..aa9ddc3 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx +++ b/codemods/misc/migrate-mui-button-to-bui-button/tests/text-button/expected.tsx @@ -1,4 +1,4 @@ - +import { Button } from '@backstage/ui'; const MyComponent = () => ( diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts index 4c3729b..51c7ea6 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts @@ -80,7 +80,7 @@ function collectIconButtonImports(rootNode: SgNode): { return { iconButtonLocalName, importNodesToRemove } } -function addButtonIconToBuiImport(rootNode: SgNode, edits: Edit[]): void { +function addButtonIconToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null @@ -110,16 +110,38 @@ function addButtonIconToBuiImport(rootNode: SgNode, edits: Edit[]): void { migrationMetric.increment({ action: 'import-merged' }) } } - } 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 { ButtonIcon } from '${BUI_SOURCE}';`)) - } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) + } else if (importNodesToRemove.length === 1) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(`import { ButtonIcon } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) } - migrationMetric.increment({ action: 'import-added' }) } + + migrationMetric.increment({ action: 'import-added' }) + return false +} + +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` } function getElementName(opening: SgNode): string | null { @@ -172,7 +194,13 @@ function isSingleIconChild(child: SgNode): boolean { /** Props that are dropped silently (MUI-specific, no BUI equivalent). */ const DROPPED_PROPS = new Set(['size', 'edge', 'color', 'disableRipple', 'disableFocusRipple']) -function transformIconButtonElements(rootNode: SgNode, iconButtonLocalName: string, edits: Edit[]): void { +function transformIconButtonElements( + rootNode: SgNode, + iconButtonLocalName: string, + edits: Edit[], +): { preserveImport: boolean; migrated: boolean } { + let preserveImport = false + let migrated = false const jsxElements = rootNode.findAll({ rule: { any: [{ kind: 'jsx_element' }, { kind: 'jsx_self_closing_element' }], @@ -191,29 +219,33 @@ function transformIconButtonElements(rootNode: SgNode, iconButtonLocalName: continue } - // Self-closing IconButton has no icon child — TODO + const insertTodo = (reason: string) => { + preserveImport = true + edits.push( + el.replace( + withTodoComment('{/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */}', el.text()), + ), + ) + migrationMetric.increment({ action: 'todo-inserted', reason }) + } + if (isSelfClosing) { - edits.push(el.replace(`{/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */}\n${el.text()}`)) - migrationMetric.increment({ action: 'todo-inserted', reason: 'no-children' }) + insertTodo('no-children') continue } - // Need exactly one icon child const children = getJsxChildren(el) const [iconChild] = children if (children.length !== 1 || !iconChild || !isSingleIconChild(iconChild)) { - edits.push(el.replace(`{/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */}\n${el.text()}`)) - migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-children' }) + insertTodo('complex-children') continue } const iconText = iconChild.text() - // Check for aria-label — required for accessibility const hasAriaLabel = hasProp(opening, 'aria-label') if (!hasAriaLabel) { - edits.push(el.replace(`{/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */}\n${el.text()}`)) - migrationMetric.increment({ action: 'todo-inserted', reason: 'missing-aria-label' }) + insertTodo('missing-aria-label') continue } @@ -271,8 +303,11 @@ function transformIconButtonElements(rootNode: SgNode, iconButtonLocalName: const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' edits.push(el.replace(``)) + migrated = true migrationMetric.increment({ action: 'icon-button-migrated' }) } + + return { preserveImport, migrated } } const transform: Codemod = (root) => { @@ -285,17 +320,23 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - // Remove MUI imports - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) - } + const { preserveImport, migrated } = transformIconButtonElements(rootNode, iconButtonLocalName, edits) - // Add BUI import - addButtonIconToBuiImport(rootNode, edits) + let replacedImport = false + if (migrated) { + replacedImport = addButtonIconToBuiImport(rootNode, importNodesToRemove, edits) + } - // Transform JSX elements - transformIconButtonElements(rootNode, iconButtonLocalName, edits) + if (!preserveImport) { + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + migrationMetric.increment({ action: 'import-removed' }) + continue + } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) } diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx index eb46ea4..553f7ad 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/basic-migration/expected.tsx @@ -1,4 +1,4 @@ - +import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( } aria-label="delete" isDisabled={!canDelete} onPress={handleDelete} /> diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx index 99b4b1d..f631327 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/className-preserved/expected.tsx @@ -1,4 +1,4 @@ - +import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( } aria-label="copy" className="custom-btn" data-testid="copy-btn" onPress={handleCopy} /> diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx index 2147d12..605e0ca 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/expected.tsx @@ -1,9 +1,11 @@ - +import IconButton from '@material-ui/core/IconButton'; const MyComponent = () => ( + <> {/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */} - + extra + ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json index 2ce4979..52188ca 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/complex-children-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-icon-button-to-button-icon": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 1 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx index 1997335..95cb4bf 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/disabled-boolean/expected.tsx @@ -1,4 +1,4 @@ - +import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( } aria-label="close" isDisabled onPress={onClose} /> diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx index c9c3eba..3f19a0f 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/dropped-props/expected.tsx @@ -1,4 +1,4 @@ - +import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( } aria-label="menu" onPress={handleMenu} /> diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx index ad71cac..f662bbf 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/expected.tsx @@ -1,8 +1,10 @@ - +import IconButton from '@material-ui/core/IconButton'; const MyComponent = () => ( + <> {/* TODO(backstage-codemod): verify ButtonIcon accessibility manually */} - + + ); diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json index 76be6b0..0e30158 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/missing-aria-label-todo/metrics.json @@ -1,17 +1,5 @@ { "migrate-mui-icon-button-to-button-icon": [ - { - "cardinality": { - "action": "import-added" - }, - "count": 1 - }, - { - "cardinality": { - "action": "import-removed" - }, - "count": 1 - }, { "cardinality": { "action": "todo-inserted", diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx index aaf6ba8..afa4621 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/named-barrel-import/expected.tsx @@ -1,4 +1,4 @@ - +import { ButtonIcon } from '@backstage/ui'; const MyComponent = () => ( } aria-label="edit" onPress={handleEdit} /> diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx index bc06025..7519265 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/tests/spread-props/expected.tsx @@ -1,4 +1,4 @@ - +import { ButtonIcon } from '@backstage/ui'; const MyComponent = (props: any) => ( } aria-label="action" {...props} /> diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml index 26d8628..91ad750 100644 --- a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/codemod.yaml @@ -11,7 +11,7 @@ workflow: 'workflow.yaml' targets: languages: ['tsx', 'ts'] -keywords: ['backstage', 'migration', 'mui', 'bui', 'tooltip', 'bui', 'tooltip'] +keywords: ['backstage', 'migration', 'mui', 'bui', 'tooltip'] registry: access: 'public' diff --git a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts index 04da22a..7c1fb99 100644 --- a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -262,6 +262,13 @@ function getAttrStringValue( return { value: '', isDynamic: false, attrNode: attr } } +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` +} + function getOpeningElement(el: SgNode): SgNode | null { if (el.is('jsx_self_closing_element')) { return el @@ -313,7 +320,9 @@ function transformTypographyElements( // Check for unmappable dynamic values if (variantDynamic || colorDynamic) { preservedLocalNames.add(componentLocalName) - edits.push(el.replace(`{/* TODO(backstage-codemod): verify Text variant manually */}\n${el.text()}`)) + edits.push( + el.replace(withTodoComment('{/* TODO(backstage-codemod): verify Text variant manually */}', el.text())), + ) migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-props' }) continue } @@ -339,7 +348,9 @@ function transformTypographyElements( if (needsTodo) { preservedLocalNames.add(componentLocalName) - edits.push(el.replace(`{/* TODO(backstage-codemod): verify Text variant manually */}\n${el.text()}`)) + edits.push( + el.replace(withTodoComment('{/* TODO(backstage-codemod): verify Text variant manually */}', el.text())), + ) migrationMetric.increment({ action: 'todo-inserted', reason: 'unmapped-variant-or-color' }) continue } @@ -384,21 +395,31 @@ function transformTypographyElements( } const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + const gutterBottomTodo = gutterBottomAttr + ? '{/* TODO(backstage-codemod): verify Text variant manually (gutterBottom) */}' + : null + + const wrapWithGutterBottomTodo = (content: string): string => { + if (!gutterBottomTodo) { + return content + } + return withTodoComment(gutterBottomTodo, content) + } if (isSelfClosing) { - edits.push(el.replace(``)) + edits.push(el.replace(wrapWithGutterBottomTodo(``))) } else { - // Preserve children via AST traversal const children = el .children() .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') .map((c) => c.text()) .join('') - edits.push(el.replace(`${children}`)) + edits.push(el.replace(wrapWithGutterBottomTodo(`${children}`))) } if (gutterBottomAttr) { + migrationMetric.increment({ action: 'todo-inserted', reason: 'gutterBottom' }) migrationMetric.increment({ action: 'gutterBottom-dropped' }) } migrated = true diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx index f76f710..4dc7973 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/expected.tsx @@ -1,5 +1,8 @@ import { Text } from '@backstage/ui'; const MyComponent = () => ( + <> + {/* TODO(backstage-codemod): verify Text variant manually (gutterBottom) */} Section Title + ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json index dab4181..00e25a8 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/gutter-bottom-dropped/metrics.json @@ -18,6 +18,13 @@ }, "count": 1 }, + { + "cardinality": { + "action": "todo-inserted", + "reason": "gutterBottom" + }, + "count": 1 + }, { "cardinality": { "action": "typography-migrated", diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx index 7efd38c..fdd8f9b 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx @@ -1,6 +1,8 @@ import Typography from '@material-ui/core/Typography'; const MyComponent = () => ( + <> {/* TODO(backstage-codemod): verify Text variant manually */} -Hidden text + Hidden text + ); From 2454d513f05a6534b894d7a2852f5049cc55d91a Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 10:49:48 -0500 Subject: [PATCH 5/6] fix: address Copilot review feedback on core component codemods Co-authored-by: Cursor --- .../scripts/codemod.ts | 69 +++++++-- .../tests/with-title/expected.tsx | 3 +- .../scripts/codemod.ts | 35 ++++- .../scripts/codemod.ts | 47 +++++- .../scripts/codemod.ts | 94 ++++++++---- .../scripts/codemod.ts | 140 ++++++++++-------- .../tests/unmapped-variant-todo/expected.tsx | 4 +- .../tests/unmapped-variant-todo/metrics.json | 19 +++ 8 files changed, 298 insertions(+), 113 deletions(-) diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts index c6d8b58..c7ffddc 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts @@ -59,14 +59,23 @@ function getNamedImportLocalName(imp: SgNode, targetName: string): string | return null } +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + function collectAlertImports(rootNode: SgNode): { alertLocalName: string | null alertTitleLocalName: string | null importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, { source: string; names: string[] }> } { let alertLocalName: string | null = null let alertTitleLocalName: string | null = null const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, { source: string; names: string[] }>() for (const imp of findImportStatementsFrom(rootNode, '@material-ui/lab/Alert')) { alertLocalName = getDefaultImportName(imp) @@ -90,6 +99,8 @@ function collectAlertImports(rootNode: SgNode): { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) if (allSpecifiers.length <= 1) { importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, { source: '@material-ui/core', names: ['Alert'] }) } } } @@ -105,16 +116,40 @@ function collectAlertImports(rootNode: SgNode): { alertTitleLocalName = alertTitleName } - if (alertName || alertTitleName) { + const namesToRemove: string[] = [] + if (alertName) { + namesToRemove.push('Alert') + } + if (alertTitleName) { + namesToRemove.push('AlertTitle') + } + + if (namesToRemove.length > 0) { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) - const alertSpecCount = (alertName ? 1 : 0) + (alertTitleName ? 1 : 0) - if (alertSpecCount >= allSpecifiers.length) { + if (namesToRemove.length >= allSpecifiers.length) { importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, { source: '@material-ui/lab', names: namesToRemove }) } } } - return { alertLocalName, alertTitleLocalName, importNodesToRemove } + return { alertLocalName, alertTitleLocalName, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '${source}';`)) + } + migrationMetric.increment({ action: 'import-removed' }) } function buildBuiImportEdit(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { @@ -156,7 +191,7 @@ function buildBuiImportEdit(rootNode: SgNode, importNodesToRemove: SgNode 0) { const [importNode] = importNodesToRemove if (importNode) { edits.push(importNode.replace(`import { Alert } from '${BUI_SOURCE}';`)) @@ -380,7 +415,8 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { alertLocalName, alertTitleLocalName, importNodesToRemove } = collectAlertImports(rootNode) + const { alertLocalName, alertTitleLocalName, importNodesToRemove, importSpecifiersToRemove } = + collectAlertImports(rootNode) if (!alertLocalName) { return Promise.resolve(null) @@ -389,18 +425,29 @@ const transform: Codemod = (root) => { const { preserveImport, migrated } = transformAlertElements(rootNode, alertLocalName, alertTitleLocalName, edits) let replacedImport = false + if (migrated && !preserveImport && importNodesToRemove.length > 1) { + for (const imp of importNodesToRemove.slice(1)) { + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + } + if (migrated) { replacedImport = buildBuiImportEdit(rootNode, importNodesToRemove, edits) } if (!preserveImport) { - for (const imp of importNodesToRemove) { - if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { + const [firstImport] = importNodesToRemove + if (firstImport) { + if (replacedImport) { + migrationMetric.increment({ action: 'import-removed' }) + } else { + edits.push(firstImport.replace('')) migrationMetric.increment({ action: 'import-removed' }) - continue } - edits.push(imp.replace('')) - migrationMetric.increment({ action: 'import-removed' }) + } + for (const [imp, { source, names }] of importSpecifiersToRemove) { + pruneBarrelImportSpecifiers(imp, source, names, edits) } } diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx index 3e48901..36ce2a6 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/with-title/expected.tsx @@ -1,7 +1,6 @@ - -import AlertTitle from '@material-ui/lab/AlertTitle'; import { Alert } from '@backstage/ui'; + const MyComponent = () => ( ); diff --git a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts index 617eb74..3377f8e 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts @@ -70,12 +70,21 @@ function getNamedImportLocalName(imp: SgNode, targetName: string): string | return null } +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + function collectButtonImports(rootNode: SgNode): { buttonLocalName: string | null importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, string[]> } { let buttonLocalName: string | null = null const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, string[]>() // Default import: import Button from '@material-ui/core/Button' for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Button')) { @@ -91,11 +100,28 @@ function collectButtonImports(rootNode: SgNode): { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) if (allSpecifiers.length <= 1) { importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, ['Button']) } } } - return { buttonLocalName, importNodesToRemove } + return { buttonLocalName, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], edits: Edit[]): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '@material-ui/core';`)) + } + migrationMetric.increment({ action: 'import-removed' }) } function addButtonToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { @@ -137,7 +163,7 @@ function addButtonToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode if (anchorImport) { edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { Button } from '${BUI_SOURCE}';`)) - } else if (importNodesToRemove.length === 1) { + } else if (importNodesToRemove.length > 0) { const [importNode] = importNodesToRemove if (importNode) { edits.push(importNode.replace(`import { Button } from '${BUI_SOURCE}';`)) @@ -371,7 +397,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { buttonLocalName, importNodesToRemove } = collectButtonImports(rootNode) + const { buttonLocalName, importNodesToRemove, importSpecifiersToRemove } = collectButtonImports(rootNode) if (!buttonLocalName) { return Promise.resolve(null) @@ -393,6 +419,9 @@ const transform: Codemod = (root) => { edits.push(imp.replace('')) migrationMetric.increment({ action: 'import-removed' }) } + for (const [imp, namesToRemove] of importSpecifiersToRemove) { + pruneBarrelImportSpecifiers(imp, namesToRemove, edits) + } } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts index 51c7ea6..a756f5a 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts @@ -52,12 +52,21 @@ function getNamedImportLocalName(imp: SgNode, targetName: string): string | return null } +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + function collectIconButtonImports(rootNode: SgNode): { iconButtonLocalName: string | null importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, string[]> } { let iconButtonLocalName: string | null = null const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, string[]>() // Default import: import IconButton from '@material-ui/core/IconButton' for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/IconButton')) { @@ -73,11 +82,28 @@ function collectIconButtonImports(rootNode: SgNode): { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) if (allSpecifiers.length <= 1) { importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, ['IconButton']) } } } - return { iconButtonLocalName, importNodesToRemove } + return { iconButtonLocalName, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], edits: Edit[]): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '@material-ui/core';`)) + } + migrationMetric.increment({ action: 'import-removed' }) } function addButtonIconToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { @@ -119,7 +145,7 @@ function addButtonIconToBuiImport(rootNode: SgNode, importNodesToRemove: Sg if (anchorImport) { edits.push(anchorImport.replace(`${anchorImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) - } else if (importNodesToRemove.length === 1) { + } else if (importNodesToRemove.length > 0) { const [importNode] = importNodesToRemove if (importNode) { edits.push(importNode.replace(`import { ButtonIcon } from '${BUI_SOURCE}';`)) @@ -186,6 +212,14 @@ function getJsxChildren(element: SgNode): SgNode[] { return children } +function formatIconProp(iconChild: SgNode): string { + const iconText = iconChild.text() + if (iconChild.kind() === 'jsx_expression') { + return `icon={${iconText.slice(1, -1)}}` + } + return `icon={${iconText}}` +} + function isSingleIconChild(child: SgNode): boolean { const kind = child.kind() return kind === 'jsx_self_closing_element' || kind === 'jsx_element' || kind === 'jsx_expression' @@ -241,8 +275,6 @@ function transformIconButtonElements( continue } - const iconText = iconChild.text() - const hasAriaLabel = hasProp(opening, 'aria-label') if (!hasAriaLabel) { insertTodo('missing-aria-label') @@ -253,7 +285,7 @@ function transformIconButtonElements( const newProps: string[] = [] // icon prop from child - newProps.push(`icon={${iconText}}`) + newProps.push(formatIconProp(iconChild)) // Map props from opening element const allAttrs = opening.findAll({ rule: { kind: 'jsx_attribute' } }) @@ -314,7 +346,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { iconButtonLocalName, importNodesToRemove } = collectIconButtonImports(rootNode) + const { iconButtonLocalName, importNodesToRemove, importSpecifiersToRemove } = collectIconButtonImports(rootNode) if (!iconButtonLocalName) { return Promise.resolve(null) @@ -336,6 +368,9 @@ const transform: Codemod = (root) => { edits.push(imp.replace('')) migrationMetric.increment({ action: 'import-removed' }) } + for (const [imp, namesToRemove] of importSpecifiersToRemove) { + pruneBarrelImportSpecifiers(imp, namesToRemove, edits) + } } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts index 5b22da6..541836b 100644 --- a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts @@ -64,12 +64,21 @@ function getNamedImportLocalName(imp: SgNode, targetName: string): string | return null } +function getImportedName(spec: SgNode): string | null { + const identifiers = spec.findAll({ + rule: { any: [{ kind: 'identifier' }, { kind: 'type_identifier' }] }, + }) + return identifiers[0]?.text() ?? null +} + function collectTooltipImports(rootNode: SgNode): { tooltipLocalName: string | null importNodesToRemove: SgNode[] + importSpecifiersToRemove: Map, string[]> } { let tooltipLocalName: string | null = null const importNodesToRemove: SgNode[] = [] + const importSpecifiersToRemove = new Map, string[]>() for (const imp of findImportStatementsFrom(rootNode, '@material-ui/core/Tooltip')) { tooltipLocalName = getDefaultImportName(imp) @@ -83,14 +92,36 @@ function collectTooltipImports(rootNode: SgNode): { const allSpecifiers = imp.findAll({ rule: { kind: 'import_specifier' } }) if (allSpecifiers.length <= 1) { importNodesToRemove.push(imp) + } else { + importSpecifiersToRemove.set(imp, ['Tooltip']) } } } - return { tooltipLocalName, importNodesToRemove } + return { tooltipLocalName, importNodesToRemove, importSpecifiersToRemove } +} + +function pruneBarrelImportSpecifiers(imp: SgNode, namesToRemove: string[], edits: Edit[]): void { + const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { + const importedName = getImportedName(spec) + return importedName !== null && !namesToRemove.includes(importedName) + }) + + if (remainingSpecs.length === 0) { + edits.push(imp.replace('')) + } else { + const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') + edits.push(imp.replace(`import { ${specTexts} } from '@material-ui/core';`)) + } + migrationMetric.increment({ action: 'import-removed' }) } -function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): void { +function addBuiImport( + rootNode: SgNode, + names: string[], + importNodesToRemove: SgNode[], + edits: Edit[], +): boolean { const existingImports = findImportStatementsFrom(rootNode, BUI_SOURCE) const existingImport = existingImports[0] ?? null @@ -112,19 +143,33 @@ function addBuiImport(rootNode: SgNode, names: string[], edits: Edit[]): vo 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}';`), - ) - } + return false + } + + const removableIds = new Set(importNodesToRemove.map((imp) => imp.id())) + const allImports = rootNode.findAll({ rule: { kind: 'import_statement' } }) + const anchorImport = [...allImports].reverse().find((imp) => !removableIds.has(imp.id())) ?? null + const sortedNames = [...names].sort() + const buiImport = `import { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';` + + if (anchorImport) { + edits.push(anchorImport.replace(`${anchorImport.text()}\n${buiImport}`)) + } else if (importNodesToRemove.length > 0) { + const [importNode] = importNodesToRemove + if (importNode) { + edits.push(importNode.replace(buiImport)) + migrationMetric.increment({ action: 'import-added' }) + return true + } + } else if (allImports.length > 0) { + const lastImport = allImports.at(-1) + if (lastImport) { + edits.push(lastImport.replace(`${lastImport.text()}\n${buiImport}`)) } - migrationMetric.increment({ action: 'import-added' }) } + + migrationMetric.increment({ action: 'import-added' }) + return false } function getElementName(opening: SgNode): string | null { @@ -372,7 +417,7 @@ const transform: Codemod = (root) => { const rootNode = root.root() const edits: Edit[] = [] - const { tooltipLocalName, importNodesToRemove } = collectTooltipImports(rootNode) + const { tooltipLocalName, importNodesToRemove, importSpecifiersToRemove } = collectTooltipImports(rootNode) if (!tooltipLocalName) { return Promise.resolve(null) @@ -385,21 +430,18 @@ const transform: Codemod = (root) => { } const buiNames = ['Tooltip', 'TooltipTrigger'] - const existingBui = findImportStatementsFrom(rootNode, BUI_SOURCE) + const replacedImport = addBuiImport(rootNode, buiNames, importNodesToRemove, edits) - if (existingBui.length === 0 && importNodesToRemove.length === 1) { - const [importToReplace] = importNodesToRemove - if (importToReplace) { - edits.push(importToReplace.replace(`import { ${buiNames.join(', ')} } from '${BUI_SOURCE}';`)) - migrationMetric.increment({ action: 'import-added' }) - migrationMetric.increment({ action: 'import-removed' }) - } - } else { - addBuiImport(rootNode, buiNames, edits) - for (const imp of importNodesToRemove) { - edits.push(imp.replace('')) + for (const imp of importNodesToRemove) { + if (replacedImport && imp.id() === importNodesToRemove[0]?.id()) { migrationMetric.increment({ action: 'import-removed' }) + continue } + edits.push(imp.replace('')) + migrationMetric.increment({ action: 'import-removed' }) + } + for (const [imp, namesToRemove] of importSpecifiersToRemove) { + pruneBarrelImportSpecifiers(imp, namesToRemove, edits) } return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) diff --git a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts index 7c1fb99..535b8ee 100644 --- a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -286,12 +286,64 @@ function getElementName(opening: SgNode): string | null { return null } -function transformTypographyElements( - rootNode: SgNode, - localNames: Map, - edits: Edit[], - preservedLocalNames: Set, -): boolean { +function buildPartialTextProps( + opening: SgNode, + buiVariant: string | null, + buiColor: string | null, + componentValue: string | null, + componentDynamic: boolean, +): string[] { + const newProps: string[] = [] + if (buiVariant) { + newProps.push(`variant="${buiVariant}"`) + } + if (buiColor) { + newProps.push(`color="${buiColor}"`) + } + if (componentValue && !componentDynamic) { + newProps.push(`as="${componentValue}"`) + } else if (componentDynamic && componentValue) { + newProps.push(`as={${componentValue.slice(1, -1)}}`) + } + + const handledProps = new Set(['variant', 'color', 'component', 'gutterBottom']) + 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 + } + 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()) + } + } + + return newProps +} + +function buildTextElement(el: SgNode, isSelfClosing: boolean, newProps: string[]): string { + const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' + if (isSelfClosing) { + return `` + } + const children = el + .children() + .filter((c) => c.kind() !== 'jsx_opening_element' && c.kind() !== 'jsx_closing_element') + .map((c) => c.text()) + .join('') + return `${children}` +} + +function transformTypographyElements(rootNode: SgNode, localNames: Map, edits: Edit[]): boolean { let migrated = false const jsxElements = rootNode.findAll({ rule: { @@ -317,13 +369,18 @@ function transformTypographyElements( const { value: componentValue, isDynamic: componentDynamic } = getAttrStringValue(opening, 'component') const { attrNode: gutterBottomAttr } = getAttrStringValue(opening, 'gutterBottom') - // Check for unmappable dynamic values if (variantDynamic || colorDynamic) { - preservedLocalNames.add(componentLocalName) + const partialProps = buildPartialTextProps(opening, null, null, componentValue, componentDynamic) + const textElement = buildTextElement(el, isSelfClosing, partialProps) edits.push( - el.replace(withTodoComment('{/* TODO(backstage-codemod): verify Text variant manually */}', el.text())), + el.replace(withTodoComment('{/* TODO(backstage-codemod): verify Text variant manually */}', textElement)), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'dynamic-props' }) + migrationMetric.increment({ + action: 'typography-migrated', + component: localNames.get(componentLocalName) ?? componentLocalName, + }) + migrated = true continue } @@ -347,53 +404,22 @@ function transformTypographyElements( } if (needsTodo) { - preservedLocalNames.add(componentLocalName) + const partialProps = buildPartialTextProps(opening, buiVariant, buiColor, componentValue, componentDynamic) + const textElement = buildTextElement(el, isSelfClosing, partialProps) edits.push( - el.replace(withTodoComment('{/* TODO(backstage-codemod): verify Text variant manually */}', el.text())), + el.replace(withTodoComment('{/* TODO(backstage-codemod): verify Text variant manually */}', textElement)), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'unmapped-variant-or-color' }) + migrationMetric.increment({ + action: 'typography-migrated', + component: localNames.get(componentLocalName) ?? componentLocalName, + }) + migrated = true continue } // Build new props - const newProps: string[] = [] - if (buiVariant) { - newProps.push(`variant="${buiVariant}"`) - } - if (buiColor) { - newProps.push(`color="${buiColor}"`) - } - // Convert component → as - if (componentValue && !componentDynamic) { - newProps.push(`as="${componentValue}"`) - } else if (componentDynamic && componentValue) { - newProps.push(`as={${componentValue.slice(1, -1)}}`) - } - - // Collect remaining props we haven't handled - const handledProps = new Set(['variant', 'color', 'component', 'gutterBottom']) - 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 - } - // Preserve any unhandled prop as-is - newProps.push(attr.text()) - } - - // Also 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 newProps = buildPartialTextProps(opening, buiVariant, buiColor, componentValue, componentDynamic) const propsStr = newProps.length > 0 ? ` ${newProps.join(' ')}` : '' const gutterBottomTodo = gutterBottomAttr ? '{/* TODO(backstage-codemod): verify Text variant manually (gutterBottom) */}' @@ -442,14 +468,9 @@ const transform: Codemod = (root) => { return Promise.resolve(null) } - const preservedLocalNames = new Set() - const migrated = transformTypographyElements(rootNode, localNames, edits, preservedLocalNames) + const migrated = transformTypographyElements(rootNode, localNames, edits) for (const imp of importNodesToRemove) { - const defaultName = getDefaultImportName(imp) - if (defaultName && preservedLocalNames.has(defaultName)) { - continue - } if ( migrated && importNodesToRemove.length === 1 && @@ -465,18 +486,11 @@ const transform: Codemod = (root) => { let addedTextViaBarrelPrune = false for (const [imp, namesToRemove] of importSpecifiersToRemove) { - const removableNames = namesToRemove.filter((componentName) => { - const localName = getNamedImportLocalName(imp, componentName) - return localName === null || !preservedLocalNames.has(localName) - }) - if (removableNames.length === 0) { - continue - } const appendTextImport = migrated && findImportStatementsFrom(rootNode, BUI_SOURCE).length === 0 if (appendTextImport) { addedTextViaBarrelPrune = true } - pruneBarrelImportSpecifiers(imp, removableNames, edits, appendTextImport) + pruneBarrelImportSpecifiers(imp, namesToRemove, edits, appendTextImport) } if (migrated && !addedTextViaBarrelPrune) { diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx index fdd8f9b..f42c68f 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx +++ b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/expected.tsx @@ -1,8 +1,8 @@ -import Typography from '@material-ui/core/Typography'; +import { Text } from '@backstage/ui'; const MyComponent = () => ( <> {/* TODO(backstage-codemod): verify Text variant manually */} - Hidden text + Hidden text ); diff --git a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json index bc94600..065f853 100644 --- a/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json +++ b/codemods/misc/migrate-mui-typography-to-text/tests/unmapped-variant-todo/metrics.json @@ -1,11 +1,30 @@ { "migrate-mui-typography-to-text": [ + { + "cardinality": { + "action": "import-added" + }, + "count": 1 + }, + { + "cardinality": { + "action": "import-removed" + }, + "count": 1 + }, { "cardinality": { "action": "todo-inserted", "reason": "unmapped-variant-or-color" }, "count": 1 + }, + { + "cardinality": { + "action": "typography-migrated", + "component": "Typography" + }, + "count": 1 } ] } \ No newline at end of file From 027d8fc0559660424e4208b62dfa8a4ed4cd2c72 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 11:14:14 -0500 Subject: [PATCH 6/6] fix: address Copilot review feedback on core component codemods Add named BUI imports when only a namespace import exists, skip default Alert icon when custom icon props are present, and dedupe codemod.yaml keywords. Co-authored-by: Cursor --- .../scripts/codemod.ts | 44 ++++++++++++++++++- .../codemod.yaml | 2 +- .../scripts/codemod.ts | 3 ++ .../codemod.yaml | 2 +- .../scripts/codemod.ts | 3 ++ .../scripts/codemod.ts | 6 +++ .../scripts/codemod.ts | 3 ++ 7 files changed, 59 insertions(+), 4 deletions(-) diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts index c7ffddc..b7f3f8c 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/scripts/codemod.ts @@ -180,6 +180,9 @@ function buildBuiImportEdit(rootNode: SgNode, importNodesToRemove: SgNode, propName: string): boolean { return attr !== null } +function getPropRawValue(opening: SgNode, propName: string): string | null { + const attr = opening.find({ + rule: { + kind: 'jsx_attribute', + has: { + kind: 'property_identifier', + regex: `^${escapeRegex(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 '' +} + +function shouldAddDefaultIcon(opening: SgNode): boolean { + if (hasProp(opening, 'iconMapping')) { + return false + } + if (!hasProp(opening, 'icon')) { + return true + } + const iconValue = getPropRawValue(opening, 'icon') + return iconValue === '' || iconValue === '{true}' || iconValue === 'true' +} + function extractChildContent( element: SgNode, alertTitleLocalName: string | null, @@ -378,7 +414,9 @@ function transformAlertElements( if (status) { props.push(`status="${status}"`) } - props.push('icon') + if (shouldAddDefaultIcon(opening)) { + props.push('icon') + } edits.push(el.replace(``)) migrated = true migrationMetric.increment({ action: 'alert-migrated', variant: 'self-closing' }) @@ -396,7 +434,9 @@ function transformAlertElements( if (status) { props.push(`status="${status}"`) } - props.push('icon') + if (shouldAddDefaultIcon(opening)) { + props.push('icon') + } if (title) { props.push(`title="${title}"`) } diff --git a/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml b/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml index be1005b..e7f0331 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml +++ b/codemods/misc/migrate-mui-button-to-bui-button/codemod.yaml @@ -11,7 +11,7 @@ workflow: 'workflow.yaml' targets: languages: ['tsx', 'ts'] -keywords: ['backstage', 'migration', 'mui', 'bui', 'button', 'bui', 'button'] +keywords: ['backstage', 'migration', 'mui', 'bui', 'button'] registry: access: 'public' diff --git a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts index 3377f8e..3b51f1c 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-button-to-bui-button/scripts/codemod.ts @@ -152,6 +152,9 @@ function addButtonToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode names.sort() edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\nimport { Button } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) } } return false diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml b/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml index bbbf6d4..9ab9b40 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/codemod.yaml @@ -11,7 +11,7 @@ workflow: 'workflow.yaml' targets: languages: ['tsx', 'ts'] -keywords: ['backstage', 'migration', 'mui', 'bui', 'icon', 'button', 'button', 'icon'] +keywords: ['backstage', 'migration', 'mui', 'bui', 'icon-button', 'button-icon'] registry: access: 'public' diff --git a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts index a756f5a..0837528 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/scripts/codemod.ts @@ -134,6 +134,9 @@ function addButtonIconToBuiImport(rootNode: SgNode, importNodesToRemove: Sg names.sort() edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) migrationMetric.increment({ action: 'import-merged' }) + } else { + edits.push(existingImport.replace(`${existingImport.text()}\nimport { ButtonIcon } from '${BUI_SOURCE}';`)) + migrationMetric.increment({ action: 'import-added' }) } } return false diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts index 541836b..2480fac 100644 --- a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts @@ -142,6 +142,12 @@ function addBuiImport( existing.sort() edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) migrationMetric.increment({ action: 'import-merged' }) + } else { + const sortedNames = [...names].sort() + edits.push( + existingImport.replace(`${existingImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`), + ) + migrationMetric.increment({ action: 'import-added' }) } return false } diff --git a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts index 535b8ee..bdf6759 100644 --- a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -202,6 +202,9 @@ function addTextToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode