From 264eb4cc5d39e6b0345ce382e6c123cbbf930eb4 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 24 Jun 2026 21:22:04 -0500 Subject: [PATCH 1/9] 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 00000000..496f5504 --- /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 00000000..f814c629 --- /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 00000000..8c430069 --- /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 00000000..eea18bc3 --- /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 00000000..2c1d276f --- /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 00000000..6a032910 --- /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 00000000..605c01a6 --- /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 00000000..bb49a482 --- /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 00000000..c4e04d1d --- /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 00000000..651450fa --- /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 00000000..6f8e059f --- /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 00000000..465c4b25 --- /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 00000000..465c4b25 --- /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 00000000..465c4b25 --- /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 00000000..465c4b25 --- /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 00000000..f8851948 --- /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 00000000..1e8dbe68 --- /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 00000000..bb49a482 --- /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 00000000..34b81353 --- /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 00000000..d8e24efa --- /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 00000000..d6951da2 --- /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 00000000..5ad4d6f0 --- /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 00000000..bd04f9c0 --- /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 00000000..e312afe1 --- /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 00000000..be1005be --- /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 00000000..1aff35b4 --- /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 00000000..b399cc7c --- /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 00000000..5dbc8998 --- /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 00000000..b7494350 --- /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 00000000..29e0200b --- /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 00000000..bd8a176d --- /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 00000000..3da3ab85 --- /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 00000000..2526f214 --- /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 00000000..fad20184 --- /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 00000000..2beb102e --- /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 00000000..29e0200b --- /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 00000000..1e8eab4f --- /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 00000000..16bc1d1e --- /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 00000000..29e0200b --- /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 00000000..ced449c3 --- /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 00000000..1036aea1 --- /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 00000000..2215f456 --- /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 00000000..2e1df73e --- /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 00000000..79f6facf --- /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 00000000..2c31eb85 --- /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 00000000..01394fb0 --- /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 00000000..a03f3403 --- /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 00000000..5dcb702b --- /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 00000000..81a9ad7f --- /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 00000000..4cf3363d --- /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 00000000..7e7e452d --- /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 00000000..7eb642c0 --- /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 00000000..7eb642c0 --- /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 00000000..ae5fde4e --- /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 00000000..181787b5 --- /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 00000000..5dcb702b --- /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 00000000..b711f76c --- /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 00000000..c29040e2 --- /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 00000000..aedf7388 --- /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 00000000..2141afea --- /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 00000000..a8f6ea3f --- /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 00000000..9c805c3a --- /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 00000000..ce5909f1 --- /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 00000000..2e5959cf --- /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 00000000..aedf7388 --- /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 00000000..5ad4d6f0 --- /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 00000000..4d765273 --- /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 00000000..37354d39 --- /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 00000000..bbbf6d48 --- /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 00000000..bdb4f0a4 --- /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 00000000..161178c4 --- /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 00000000..eb46ea40 --- /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 00000000..0f099bb9 --- /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 00000000..aa362170 --- /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 00000000..99b4b1d0 --- /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 00000000..d1c2a3e1 --- /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 00000000..aa362170 --- /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 00000000..2147d127 --- /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 00000000..c82139c1 --- /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 00000000..2ce49790 --- /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 00000000..19973358 --- /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 00000000..c64057e9 --- /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 00000000..aa362170 --- /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 00000000..c9c3ebaf --- /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 00000000..eb387ea4 --- /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 00000000..f38e6179 --- /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 00000000..9ed49953 --- /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 00000000..3f21bf4d --- /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 00000000..43d525fd --- /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 00000000..ad71cac2 --- /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 00000000..b500ecf3 --- /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 00000000..76be6b0a --- /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 00000000..aaf6ba80 --- /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 00000000..4e371e10 --- /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 00000000..aa362170 --- /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 00000000..d27199f4 --- /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 00000000..d27199f4 --- /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 00000000..bc060254 --- /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 00000000..aee1ce8f --- /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 00000000..aa362170 --- /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 00000000..5ad4d6f0 --- /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 00000000..ac5b58be --- /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 00000000..c167960d --- /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 00000000..26d8628c --- /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 00000000..12cad938 --- /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 00000000..d19f5b64 --- /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 00000000..c6020413 --- /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 00000000..ce5035e3 --- /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 00000000..f5c0ac6c --- /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 00000000..b18f1ae2 --- /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 00000000..ab072edf --- /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 00000000..d22f9254 --- /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 00000000..3d1ddb9c --- /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 00000000..013dfd2d --- /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 00000000..9fd7f1b8 --- /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 00000000..0642311c --- /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 00000000..01ce08cf --- /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 00000000..d22f9254 --- /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 00000000..f78bc632 --- /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 00000000..f78bc632 --- /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 00000000..5ad3080a --- /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 00000000..c4155c2f --- /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 00000000..ea9ce793 --- /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 00000000..07f66b17 --- /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 00000000..b2f6fc36 --- /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 00000000..d22f9254 --- /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 00000000..5ad4d6f0 --- /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 00000000..2c6f7821 --- /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 00000000..ed602264 --- /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 00000000..80104b53 --- /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 00000000..e5940aa7 --- /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 00000000..452cbd8b --- /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 00000000..5070b944 --- /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 00000000..b3fc5af8 --- /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 00000000..df5928a0 --- /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 00000000..8f07c9db --- /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 00000000..7ab325fc --- /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 00000000..df5928a0 --- /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 00000000..ec88b262 --- /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 00000000..3088e480 --- /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 00000000..37cd4fba --- /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 00000000..6fe8eb6c --- /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 00000000..e4568173 --- /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 00000000..df5928a0 --- /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 00000000..f76f7101 --- /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 00000000..1ddb8e65 --- /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 00000000..dab41819 --- /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 00000000..e3ccbb60 --- /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 00000000..42bf3823 --- /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 00000000..df5928a0 --- /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 00000000..d37b4fee --- /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 00000000..2ff0cd2e --- /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 00000000..83d6a254 --- /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 00000000..78bc5c1e --- /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 00000000..fff07d18 --- /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 00000000..df5928a0 --- /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 00000000..ffcabf49 --- /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 00000000..ffcabf49 --- /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 00000000..ffcabf49 --- /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 00000000..ffcabf49 --- /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 00000000..4d16d587 --- /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 00000000..59d9719a --- /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 00000000..df5928a0 --- /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 00000000..7efd38c4 --- /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 00000000..57b1642d --- /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 00000000..bc946009 --- /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 00000000..5ad4d6f0 --- /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 00000000..ee784f37 --- /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 a07004c9..b58bddec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -172,6 +172,15 @@ __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-bootstrap-to-bui@workspace:codemods/misc/migrate-mui-bootstrap-to-bui": version: 0.0.0-use.local resolution: "@backstage/migrate-mui-bootstrap-to-bui@workspace:codemods/misc/migrate-mui-bootstrap-to-bui" @@ -181,6 +190,24 @@ __metadata: 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-icons-to-remix-icons@workspace:codemods/misc/migrate-mui-icons-to-remix-icons": version: 0.0.0-use.local resolution: "@backstage/migrate-mui-icons-to-remix-icons@workspace:codemods/misc/migrate-mui-icons-to-remix-icons" @@ -208,6 +235,24 @@ __metadata: 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 0a23864fe8f85da4f2db92e36f3c16a7237a749f Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:23:14 -0500 Subject: [PATCH 2/9] 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 2c1d276f..bc21f11d 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 b399cc7c..16be7a9b 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 161178c4..4c3729b9 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 d19f5b64..5b22da66 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 452cbd8b..04da22af 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 fe1f66a5cdb920b128f4716ce58ea32a4b2aa715 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 09:51:28 -0500 Subject: [PATCH 3/9] 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 bc21f11d..c6d8b586 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 6a032910..793345b8 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 f8851948..5c7e52e0 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 34b81353..3e489017 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 16be7a9b..617eb744 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 5dbc8998..69d5b801 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 bd8a176d..c27b952a 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 2526f214..c2578da3 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 fad20184..314bb3af 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 ced449c3..70c06767 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 2215f456..0db75b62 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 01394fb0..2decf0db 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 81a9ad7f..cf010eff 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 ae5fde4e..16c64c0c 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 b711f76c..209b8f4c 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 2141afea..bb97928f 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 9c805c3a..00f029c2 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 ce5909f1..aa9ddc3d 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 4c3729b9..51c7ea68 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 eb46ea40..553f7add 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 99b4b1d0..f631327b 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 2147d127..605e0ca0 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 2ce49790..52188ca1 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 19973358..95cb4bf8 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 c9c3ebaf..3f19a0fc 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 ad71cac2..f662bbf7 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 76be6b0a..0e301582 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 aaf6ba80..afa4621f 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 bc060254..7519265b 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 26d8628c..91ad7502 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 04da22af..7c1fb998 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 f76f7101..4dc7973c 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 dab41819..00e25a87 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 7efd38c4..fdd8f9bd 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 3f062be7badb7e50ac4620f8905ded895e3f11fe Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 10:49:48 -0500 Subject: [PATCH 4/9] 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 c6d8b586..c7ffddc9 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 3e489017..36ce2a63 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 617eb744..3377f8ee 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 51c7ea68..a756f5ae 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 5b22da66..541836b9 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 7c1fb998..535b8ee4 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 fdd8f9bd..f42c68f8 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 bc946009..065f8537 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 fdcc52e5a62deeaeb0f77e5e94ea21cba59e5d70 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Thu, 25 Jun 2026 11:14:14 -0500 Subject: [PATCH 5/9] 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 c7ffddc9..b7f3f8c2 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 be1005be..e7f03313 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 3377f8ee..3b51f1c0 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 bbbf6d48..9ab9b40f 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 a756f5ae..08375285 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 541836b9..2480fac6 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 535b8ee4..bdf67593 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 Date: Wed, 22 Jul 2026 15:17:55 -0400 Subject: [PATCH 6/9] fix: align core MUI-to-BUI helpers and suite consistency Unify BUI import merge / withTodoComment patterns across Alert and Tooltip, dedupe fixtures and keywords, fill CHANGELOG 0.1.0 sections, and match async transform wrappers to the suite golden dialect. Co-authored-by: Cursor --- .../CHANGELOG.md | 6 ++ .../codemod.yaml | 2 +- .../scripts/codemod.ts | 59 +++++++----- .../tests/noop-no-mui-import/expected.tsx | 5 - .../tests/noop-no-mui-import/input.tsx | 5 - .../tests/partial-barrel-import/expected.tsx | 9 ++ .../tests/partial-barrel-import/input.tsx | 8 ++ .../tests/partial-barrel-import/metrics.json | 23 +++++ .../CHANGELOG.md | 6 ++ .../scripts/codemod.ts | 23 +---- .../CHANGELOG.md | 6 ++ .../codemod.yaml | 2 +- .../package.json | 2 +- .../scripts/codemod.ts | 23 +---- .../CHANGELOG.md | 6 ++ .../scripts/codemod.ts | 96 ++++++++++++------- .../tests/placement-dropped/expected.tsx | 4 +- .../CHANGELOG.md | 6 ++ .../codemod.yaml | 2 +- .../package.json | 2 +- .../scripts/codemod.ts | 23 +---- .../tests/noop-no-mui-import/expected.tsx | 5 - .../tests/noop-no-mui-import/input.tsx | 5 - 23 files changed, 189 insertions(+), 139 deletions(-) delete mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/expected.tsx delete 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/partial-barrel-import/expected.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/input.tsx create mode 100644 codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/metrics.json delete mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/expected.tsx delete mode 100644 codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/input.tsx diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md b/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md index f814c629..bf3ea558 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/CHANGELOG.md @@ -1 +1,7 @@ # @backstage/migrate-mui-alert-to-bui-alert + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI `Alert` / `AlertTitle` to Backstage UI `Alert` (severity→status, title/description props) during the MUI 4 to BUI migration. diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml b/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml index 8c430069..4570381f 100644 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/codemod.yaml @@ -11,7 +11,7 @@ workflow: 'workflow.yaml' targets: languages: ['tsx', 'ts'] -keywords: ['backstage', 'migration', 'mui', 'bui', 'alert', 'bui', 'alert'] +keywords: ['backstage', 'migration', 'mui', 'bui', 'alert'] registry: access: 'public' 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 b7f3f8c2..77f60810 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 @@ -137,7 +137,13 @@ function collectAlertImports(rootNode: SgNode): { return { alertLocalName, alertTitleLocalName, importNodesToRemove, importSpecifiersToRemove } } -function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRemove: string[], edits: Edit[]): void { +function pruneBarrelImportSpecifiers( + imp: SgNode, + source: string, + namesToRemove: string[], + edits: Edit[], + appendAlertImport = false, +): void { const remainingSpecs = imp.findAll({ rule: { kind: 'import_specifier' } }).filter((spec) => { const importedName = getImportedName(spec) return importedName !== null && !namesToRemove.includes(importedName) @@ -147,35 +153,27 @@ function pruneBarrelImportSpecifiers(imp: SgNode, source: string, namesToRe edits.push(imp.replace('')) } else { const specTexts = remainingSpecs.map((spec) => spec.text()).join(', ') - edits.push(imp.replace(`import { ${specTexts} } from '${source}';`)) + let replacement = `import { ${specTexts} } from '${source}';` + if (appendAlertImport) { + replacement += `\nimport { Alert } from '${BUI_SOURCE}';` + migrationMetric.increment({ action: 'import-added' }) + } + edits.push(imp.replace(replacement)) } migrationMetric.increment({ action: 'import-removed' }) } -function buildBuiImportEdit(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { +function addBuiImport(rootNode: SgNode, importNodesToRemove: SgNode[], edits: Edit[]): boolean { 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 - } - } + const hasAlert = specifiers.some((spec) => getImportedName(spec) === 'Alert') 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) + const names = specifiers.map((spec) => spec.text()) names.push('Alert') names.sort() edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) @@ -451,7 +449,7 @@ function transformAlertElements( return { preserveImport, migrated } } -const transform: Codemod = (root) => { +const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] @@ -459,12 +457,14 @@ const transform: Codemod = (root) => { collectAlertImports(rootNode) if (!alertLocalName) { - return Promise.resolve(null) + return null } const { preserveImport, migrated } = transformAlertElements(rootNode, alertLocalName, alertTitleLocalName, edits) let replacedImport = false + let addedViaBarrelPrune = false + if (migrated && !preserveImport && importNodesToRemove.length > 1) { for (const imp of importNodesToRemove.slice(1)) { edits.push(imp.replace('')) @@ -472,8 +472,18 @@ const transform: Codemod = (root) => { } } - if (migrated) { - replacedImport = buildBuiImportEdit(rootNode, importNodesToRemove, edits) + if (!preserveImport) { + for (const [imp, { source, names }] of importSpecifiersToRemove) { + const appendAlertImport = migrated && findImportStatementsFrom(rootNode, BUI_SOURCE).length === 0 + if (appendAlertImport) { + addedViaBarrelPrune = true + } + pruneBarrelImportSpecifiers(imp, source, names, edits, appendAlertImport) + } + } + + if (migrated && !addedViaBarrelPrune) { + replacedImport = addBuiImport(rootNode, importNodesToRemove, edits) } if (!preserveImport) { @@ -486,12 +496,9 @@ const transform: Codemod = (root) => { migrationMetric.increment({ action: 'import-removed' }) } } - for (const [imp, { source, names }] of importSpecifiersToRemove) { - pruneBarrelImportSpecifiers(imp, source, names, edits) - } } - return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return edits.length > 0 ? rootNode.commitEdits(edits) : null } export default transform 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 deleted file mode 100644 index 465c4b25..00000000 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/expected.tsx +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 465c4b25..00000000 --- a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/noop-no-mui-import/input.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Alert } from '@backstage/ui'; - -const MyComponent = () => ( - -); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/expected.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/expected.tsx new file mode 100644 index 00000000..03ab824e --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/expected.tsx @@ -0,0 +1,9 @@ +import { Button } from '@material-ui/core'; +import { Alert } from '@backstage/ui'; + +const MyComponent = () => ( + <> + + + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/input.tsx b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/input.tsx new file mode 100644 index 00000000..e11782bf --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/input.tsx @@ -0,0 +1,8 @@ +import { Alert, Button } from '@material-ui/core'; + +const MyComponent = () => ( + <> + Note + + +); diff --git a/codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/metrics.json b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-import/metrics.json new file mode 100644 index 00000000..bb49a482 --- /dev/null +++ b/codemods/misc/migrate-mui-alert-to-bui-alert/tests/partial-barrel-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-button-to-bui-button/CHANGELOG.md b/codemods/misc/migrate-mui-button-to-bui-button/CHANGELOG.md index e312afe1..79ad4c2f 100644 --- a/codemods/misc/migrate-mui-button-to-bui-button/CHANGELOG.md +++ b/codemods/misc/migrate-mui-button-to-bui-button/CHANGELOG.md @@ -1 +1,7 @@ # @backstage/migrate-mui-button-to-bui-button + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI `Button` to Backstage UI `Button` with variant mapping (contained→primary, outlined→secondary, text→tertiary) during the MUI 4 to BUI migration. 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 3b51f1c0..36a744c3 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 @@ -130,24 +130,11 @@ function addButtonToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode 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 - } - } + const hasButton = specifiers.some((spec) => getImportedName(spec) === 'Button') 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) + const names = specifiers.map((spec) => spec.text()) names.push('Button') names.sort() edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) @@ -396,14 +383,14 @@ function transformButtonElements( return { preserveImport, migrated } } -const transform: Codemod = (root) => { +const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] const { buttonLocalName, importNodesToRemove, importSpecifiersToRemove } = collectButtonImports(rootNode) if (!buttonLocalName) { - return Promise.resolve(null) + return null } const { preserveImport, migrated } = transformButtonElements(rootNode, buttonLocalName, edits) @@ -427,7 +414,7 @@ const transform: Codemod = (root) => { } } - return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return edits.length > 0 ? rootNode.commitEdits(edits) : null } export default transform 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 index 37354d39..5364cc46 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/CHANGELOG.md +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/CHANGELOG.md @@ -1 +1,7 @@ # @backstage/migrate-mui-icon-button-to-button-icon + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI `IconButton` to Backstage UI `ButtonIcon` during the MUI 4 to BUI migration. 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 9ab9b40f..24148368 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 @@ -2,7 +2,7 @@ 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' +description: 'MUI 4 to BUI: Replace IconButton with ButtonIcon. Package name omits -bui- because ButtonIcon is the BUI export name.' author: 'Paul Schultz' license: 'Apache-2.0' repository: 'https://github.com/backstage/codemods' 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 index bdb4f0a4..ccb980aa 100644 --- a/codemods/misc/migrate-mui-icon-button-to-button-icon/package.json +++ b/codemods/misc/migrate-mui-icon-button-to-button-icon/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/migrate-mui-icon-button-to-button-icon", "version": "0.1.0", - "description": "MUI 4 to BUI: Replace IconButton with ButtonIcon", + "description": "MUI 4 to BUI: Replace IconButton with ButtonIcon. Package name omits -bui- because ButtonIcon is the BUI export name.", "type": "module", "scripts": { "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" 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 08375285..3fe38193 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 @@ -112,24 +112,11 @@ function addButtonIconToBuiImport(rootNode: SgNode, importNodesToRemove: Sg 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 - } - } + const hasButtonIcon = specifiers.some((spec) => getImportedName(spec) === 'ButtonIcon') 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) + const names = specifiers.map((spec) => spec.text()) names.push('ButtonIcon') names.sort() edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) @@ -345,14 +332,14 @@ function transformIconButtonElements( return { preserveImport, migrated } } -const transform: Codemod = (root) => { +const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] const { iconButtonLocalName, importNodesToRemove, importSpecifiersToRemove } = collectIconButtonImports(rootNode) if (!iconButtonLocalName) { - return Promise.resolve(null) + return null } const { preserveImport, migrated } = transformIconButtonElements(rootNode, iconButtonLocalName, edits) @@ -376,7 +363,7 @@ const transform: Codemod = (root) => { } } - return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return edits.length > 0 ? rootNode.commitEdits(edits) : null } export default transform diff --git a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md index c167960d..1de08a1f 100644 --- a/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md +++ b/codemods/misc/migrate-mui-tooltip-to-bui-tooltip/CHANGELOG.md @@ -1 +1,7 @@ # @backstage/migrate-mui-tooltip-to-bui-tooltip + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI `Tooltip` to Backstage UI `TooltipTrigger` / `Tooltip` during the MUI 4 to BUI migration. 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 2480fac6..c7f09c40 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 @@ -126,28 +126,28 @@ function addBuiImport( 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) - } + const specifiers = existingImport.findAll({ rule: { kind: 'import_specifier' } }) + const existingImportedNames = new Set( + specifiers.map((spec) => getImportedName(spec)).filter((n): n is string => n !== null), + ) + const namesToAdd = names.filter((name) => !existingImportedNames.has(name)) + if (namesToAdd.length > 0) { + const namedImports = existingImport.find({ rule: { kind: 'named_imports' } }) + if (namedImports) { + const existing = specifiers.map((spec) => spec.text()) + existing.push(...namesToAdd) + existing.sort() + edits.push(namedImports.replace(`{ ${existing.join(', ')} }`)) + migrationMetric.increment({ action: 'import-merged' }) + } else { + const sortedNames = [...namesToAdd].sort() + edits.push( + existingImport.replace( + `${existingImport.text()}\nimport { ${sortedNames.join(', ')} } from '${BUI_SOURCE}';`, + ), + ) + migrationMetric.increment({ action: 'import-added' }) } - 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 } @@ -178,6 +178,13 @@ function addBuiImport( return false } +function withTodoComment(comment: string, elementText: string): string { + return `<> + ${comment} + ${elementText} +` +} + function getElementName(opening: SgNode): string | null { for (const child of opening.children()) { if (child.is('identifier') || child.is('member_expression')) { @@ -355,7 +362,11 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin } if (needsTodo) { - edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + edits.push( + el.replace( + withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text()), + ), + ) migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) continue } @@ -365,20 +376,32 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin const titleRaw = getPropRawValue(opening, 'title') if (!titleStr && !titleDynamic) { - edits.push(el.replace(`{/* TODO(backstage-codemod): finish tooltip migration manually */}\n${el.text()}`)) + edits.push( + el.replace( + withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', 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()}`)) + edits.push( + el.replace( + withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', 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()}`)) + edits.push( + el.replace( + withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text()), + ), + ) migrationMetric.increment({ action: 'todo-inserted', reason: 'multiple-children' }) continue } @@ -400,17 +423,24 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin 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' + const needsPlacementTodo = Boolean(placementValue || placementDynamic) + if (needsPlacementTodo) { migrationMetric.increment({ action: 'placement-dropped', value: placementValue ?? 'dynamic' }) } const tooltipEl = `${tooltipContent}` const triggerProps = controlledProps.length > 0 ? ` ${controlledProps.join(' ')}` : '' + const migratedElement = `\n ${childText}\n ${tooltipEl}\n` edits.push( - el.replace(`${placementTodo}\n ${childText}\n ${tooltipEl}\n`), + el.replace( + needsPlacementTodo + ? withTodoComment( + '{/* TODO(backstage-codemod): verify Tooltip placement mapping manually */}', + migratedElement, + ) + : migratedElement, + ), ) migrationMetric.increment({ action: 'tooltip-migrated' }) migrated = true @@ -419,20 +449,20 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin return migrated } -const transform: Codemod = (root) => { +const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] const { tooltipLocalName, importNodesToRemove, importSpecifiersToRemove } = collectTooltipImports(rootNode) if (!tooltipLocalName) { - return Promise.resolve(null) + return null } const migrated = transformTooltipElements(rootNode, tooltipLocalName, edits) if (!migrated) { - return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return edits.length > 0 ? rootNode.commitEdits(edits) : null } const buiNames = ['Tooltip', 'TooltipTrigger'] @@ -450,7 +480,7 @@ const transform: Codemod = (root) => { pruneBarrelImportSpecifiers(imp, namesToRemove, edits) } - return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return edits.length > 0 ? rootNode.commitEdits(edits) : null } export default transform 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 index 5ad3080a..c8fd58ca 100644 --- 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 @@ -1,9 +1,11 @@ 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-typography-to-text/CHANGELOG.md b/codemods/misc/migrate-mui-typography-to-text/CHANGELOG.md index ed602264..3fb70e6e 100644 --- a/codemods/misc/migrate-mui-typography-to-text/CHANGELOG.md +++ b/codemods/misc/migrate-mui-typography-to-text/CHANGELOG.md @@ -1 +1,7 @@ # @backstage/migrate-mui-typography-to-text + +## 0.1.0 + +### Minor Changes + +- Initial release: migrate MUI `Typography` / `DialogContentText` to Backstage UI `Text` during the MUI 4 to BUI migration. diff --git a/codemods/misc/migrate-mui-typography-to-text/codemod.yaml b/codemods/misc/migrate-mui-typography-to-text/codemod.yaml index 80104b53..cb667c11 100644 --- a/codemods/misc/migrate-mui-typography-to-text/codemod.yaml +++ b/codemods/misc/migrate-mui-typography-to-text/codemod.yaml @@ -2,7 +2,7 @@ schema_version: '1.0' name: '@backstage/migrate-mui-typography-to-text' version: '0.1.0' -description: 'MUI 4 to BUI: Replace Typography with Text' +description: 'MUI 4 to BUI: Replace Typography with Text. Package name omits -bui- because Text is the BUI export name.' author: 'Paul Schultz' license: 'Apache-2.0' repository: 'https://github.com/backstage/codemods' diff --git a/codemods/misc/migrate-mui-typography-to-text/package.json b/codemods/misc/migrate-mui-typography-to-text/package.json index e5940aa7..73eebb78 100644 --- a/codemods/misc/migrate-mui-typography-to-text/package.json +++ b/codemods/misc/migrate-mui-typography-to-text/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/migrate-mui-typography-to-text", "version": "0.1.0", - "description": "MUI 4 to BUI: Replace Typography with Text", + "description": "MUI 4 to BUI: Replace Typography with Text. Package name omits -bui- because Text is the BUI export name.", "type": "module", "scripts": { "test": "yarn exec codemod jssg test -l tsx ./scripts/codemod.ts ./tests && yarn exec codemod workflow validate -w workflow.yaml" 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 bdf67593..e44ad397 100644 --- a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -180,24 +180,11 @@ function addTextToBuiImport(rootNode: SgNode, importNodesToRemove: SgNode getImportedName(spec) === 'Text') 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) + const names = specifiers.map((spec) => spec.text()) names.push('Text') names.sort() edits.push(namedImports.replace(`{ ${names.join(', ')} }`)) @@ -461,14 +448,14 @@ function transformTypographyElements(rootNode: SgNode, localNames: Map = (root) => { +const transform: Codemod = async (root) => { const rootNode = root.root() const edits: Edit[] = [] const { localNames, importNodesToRemove, importSpecifiersToRemove } = collectTypographyImports(rootNode) if (localNames.size === 0) { - return Promise.resolve(null) + return null } const migrated = transformTypographyElements(rootNode, localNames, edits) @@ -500,7 +487,7 @@ const transform: Codemod = (root) => { addTextToBuiImport(rootNode, importNodesToRemove, edits) } - return Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return edits.length > 0 ? rootNode.commitEdits(edits) : null } export default transform 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 deleted file mode 100644 index ffcabf49..00000000 --- a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/expected.tsx +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index ffcabf49..00000000 --- a/codemods/misc/migrate-mui-typography-to-text/tests/noop-no-mui-import/input.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Text } from '@backstage/ui'; - -const MyComponent = () => ( - Already migrated -); From d57530f4891f9262357ec6f559c00bd07eb4c248 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 15:24:48 -0400 Subject: [PATCH 7/9] style: format tooltip codemod for CI Co-authored-by: Cursor --- .../scripts/codemod.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) 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 c7f09c40..11533bdc 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 @@ -363,9 +363,7 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin if (needsTodo) { edits.push( - el.replace( - withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text()), - ), + el.replace(withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text())), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'complex-props' }) continue @@ -377,9 +375,7 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin if (!titleStr && !titleDynamic) { edits.push( - el.replace( - withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text()), - ), + el.replace(withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text())), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'no-title' }) continue @@ -387,9 +383,7 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin if (isSelfClosing) { edits.push( - el.replace( - withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text()), - ), + el.replace(withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text())), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'no-children' }) continue @@ -398,9 +392,7 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin const children = getJsxChildren(el) if (children.length !== 1) { edits.push( - el.replace( - withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text()), - ), + el.replace(withTodoComment('{/* TODO(backstage-codemod): finish tooltip migration manually */}', el.text())), ) migrationMetric.increment({ action: 'todo-inserted', reason: 'multiple-children' }) continue From 95a0bb5f4b30ad4f880717d4fd25a886de9b5067 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 15:28:15 -0400 Subject: [PATCH 8/9] fix: satisfy oxlint require-await on async transforms Co-authored-by: Cursor --- .../migrate-mui-alert-to-bui-alert/scripts/codemod.ts | 3 ++- .../migrate-mui-button-to-bui-button/scripts/codemod.ts | 3 ++- .../scripts/codemod.ts | 3 ++- .../migrate-mui-tooltip-to-bui-tooltip/scripts/codemod.ts | 8 +++++--- .../migrate-mui-typography-to-text/scripts/codemod.ts | 3 ++- 5 files changed, 13 insertions(+), 7 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 77f60810..08aea30c 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 @@ -498,7 +498,8 @@ const transform: Codemod = async (root) => { } } - return edits.length > 0 ? rootNode.commitEdits(edits) : null + const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return result } 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 36a744c3..625ae4f4 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 @@ -414,7 +414,8 @@ const transform: Codemod = async (root) => { } } - return edits.length > 0 ? rootNode.commitEdits(edits) : null + const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return result } 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 3fe38193..a2c2dc11 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 @@ -363,7 +363,8 @@ const transform: Codemod = async (root) => { } } - return edits.length > 0 ? rootNode.commitEdits(edits) : null + const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return result } 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 11533bdc..d1fe0978 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 @@ -415,7 +415,7 @@ function transformTooltipElements(rootNode: SgNode, tooltipLocalName: strin const placementValue = getPropStringValue(opening, 'placement') const placementDynamic = isPropDynamic(opening, 'placement') - const needsPlacementTodo = Boolean(placementValue || placementDynamic) + const needsPlacementTodo = Boolean(placementValue ?? placementDynamic) if (needsPlacementTodo) { migrationMetric.increment({ action: 'placement-dropped', value: placementValue ?? 'dynamic' }) } @@ -454,7 +454,8 @@ const transform: Codemod = async (root) => { const migrated = transformTooltipElements(rootNode, tooltipLocalName, edits) if (!migrated) { - return edits.length > 0 ? rootNode.commitEdits(edits) : null + const early = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return early } const buiNames = ['Tooltip', 'TooltipTrigger'] @@ -472,7 +473,8 @@ const transform: Codemod = async (root) => { pruneBarrelImportSpecifiers(imp, namesToRemove, edits) } - return edits.length > 0 ? rootNode.commitEdits(edits) : null + const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return result } 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 e44ad397..87824ba0 100644 --- a/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts +++ b/codemods/misc/migrate-mui-typography-to-text/scripts/codemod.ts @@ -487,7 +487,8 @@ const transform: Codemod = async (root) => { addTextToBuiImport(rootNode, importNodesToRemove, edits) } - return edits.length > 0 ? rootNode.commitEdits(edits) : null + const result = await Promise.resolve(edits.length > 0 ? rootNode.commitEdits(edits) : null) + return result } export default transform From b7de650499fb988648c4ada6906dc87ab3719262 Mon Sep 17 00:00:00 2001 From: Paul Schultz Date: Wed, 22 Jul 2026 15:37:55 -0400 Subject: [PATCH 9/9] docs: regenerate README after rebase onto main Co-authored-by: Cursor --- README.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d7c217aa..a9246bd7 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,19 @@ Older versions are available in the [`codemods/`](./codemods) directory. ### misc -| Codemod | Description | -| ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -| [migrate-mui-bootstrap-to-bui](./codemods/misc/migrate-mui-bootstrap-to-bui) | MUI 4 to BUI: Bootstrap app dependencies and root CSS | -| [migrate-mui-icons-to-remix-icons](./codemods/misc/migrate-mui-icons-to-remix-icons) | MUI 4 to BUI: Replace MUI icons with Remix icons | -| [migrate-mui-layout-to-bui-layout](./codemods/misc/migrate-mui-layout-to-bui-layout) | MUI 4 to BUI: Convert common MUI layout primitives to BUI layout | -| [migrate-mui-styles-to-bui-css-modules](./codemods/misc/migrate-mui-styles-to-bui-css-modules) | MUI 4 to BUI: Migrate makeStyles usage to BUI CSS modules | -| [mui4-to-bui-migration-recipe](./codemods/misc/mui4-to-bui-migration-recipe) | MUI 4 to BUI: Migration recipe that runs every MUI→BUI codemod from the registry in a safe order. | -| [remove-mui-dependencies](./codemods/misc/remove-mui-dependencies) | MUI 4 to BUI: Remove unused @material-ui/\* dependencies from package.json | +| 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-bootstrap-to-bui](./codemods/misc/migrate-mui-bootstrap-to-bui) | MUI 4 to BUI: Bootstrap app dependencies and root CSS | +| [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. Package name omits -bui- because ButtonIcon is the BUI export name. | +| [migrate-mui-icons-to-remix-icons](./codemods/misc/migrate-mui-icons-to-remix-icons) | MUI 4 to BUI: Replace MUI icons with Remix icons | +| [migrate-mui-layout-to-bui-layout](./codemods/misc/migrate-mui-layout-to-bui-layout) | MUI 4 to BUI: Convert common MUI layout primitives to BUI layout | +| [migrate-mui-styles-to-bui-css-modules](./codemods/misc/migrate-mui-styles-to-bui-css-modules) | MUI 4 to BUI: Migrate makeStyles usage to BUI CSS modules | +| [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. Package name omits -bui- because Text is the BUI export name. | +| [mui4-to-bui-migration-recipe](./codemods/misc/mui4-to-bui-migration-recipe) | MUI 4 to BUI: Migration recipe that runs every MUI→BUI codemod from the registry in a safe order. | +| [remove-mui-dependencies](./codemods/misc/remove-mui-dependencies) | MUI 4 to BUI: Remove unused @material-ui/\* dependencies from package.json |