AIにcodemodを書かせてMantine v5→v8に爆速更新
codemodでサクッとMantine v5からv8に更新した話
こんにちは!株式会社コミュニティオのしまだい(@cimadai)です。
フレームワークのメジャーアップグレード、やらなきゃいけないとわかっていても、なかなか重い腰が上がらないこと、ありますよね?
「影響範囲どれくらいだろう...」
「時間かかりそうだな...」
「バグ出したらどうしよう...」
特に規模が大きくなってくると、こんな不安が頭をよぎって、つい後回しにしてしまいがちです。
今回、ずいぶんと前からMantine v5を使っているプロジェクトで、最新のv8までアップグレードする必要があったのですが、AIを活用することで想像以上にスムーズに進めることができたのでまとめておきます。
きっと様々な場面で使えるアプローチなんじゃないかと思います!
従来のアップグレードの課題
手作業でやると...
通常のメジャーアップグレードって、こんな感じで進めることが多いのではないでしょうか。
- 公式のマイグレーションガイドを読む
 - Breaking Changes を一つずつ確認
 - 影響のあるファイルを手動で特定
 - コードを一つずつ修正
 - テストして問題ないか確認
 
これを何十、何百のファイルに対してやるとなると、かなりの時間がかかります。しかも、手作業なのでミスも起きやすい。。。
AIに直接修正させる問題
各社Coding Agentが出揃っている状況を鑑みて、
「じゃあAgentにタスク依頼して全部やらせたらいいじゃん」と思うかもしれませんが、これにも課題があります。
- 冪等性が保証されない(修正もれや追加指示などで同じ修正を複数回実行するとまったく違う修正が入ったり...)
 - 修正範囲が予測しにくい
 - レビューが大変
 - 何かあったときのロールバックが大変
 
なんてことが起きがちです。
解決策: codemodを使ったアプローチ
今回採用したのは、AIにcodemodを生成させて、それを段階的に適用するというアプローチでした。
codemodとは?
恥ずかしながらいままでcodemodというものを知らず、以下の記事を見てそういうものがあるのか!と初めて知りました。
codemodは、「コード内の特定のパターンを検出して別のコードに置き換える」という操作を一括で適用できるものです。
例えば、以下のような変更を一括で適用できます。
- 
useComponentDefaultPropsというhookをusePropsに置き換える - Overlayコンポーネントの 
opacityプロパティをbackgroundOpacityに置き換える - 
sx propで指定しているスタイルをそれぞれ対応するstyle propに置き換える 
なぜcodemodが良いのか
- 冪等性が担保される: 同じ変換を何度実行しても安全
 - 小さな単位で実行可能: 1つの変更につき1つのcodemodに分割できる
 - レビューしやすい: 変換ロジックが明確
 - 段階的コミット可能: 安心してpushできる
 
実際の手順
Step 1: 変更内容の整理
まず、Mantine v5からv6にするために必要な変更を整理してもらいました。
アップグレードガイドやChangelogを参考にさせると良さそうです。
https://v6.mantine.dev/changelog/6-0-0/
を参考に、Mantine v5からv6にアップグレードするために必要な変更をリストアップしてください。
結果、コードベースにおいて以下のような変更が必要なことがわかりました。
- DatePicker → DatePickerInputの置き換え
 - Modalコンポーネントの 
overlay*/transition*系プロパティの階層化 - Modalコンポーネントのstylesプロパティのセレクタ変更(modal → content)
 - 
*.Groupが Group を内包しなくなったため、orientation/offset/spacing プロップは削除。配置は自分でレイアウト(Stack/Group 等)。 
Step 2: codemod生成依頼
各変更点に対して、個別にcodemodを生成してもらいました。例えば、Modalコンポーネントの overlay* / transition* 系プロパティの階層化用のcodemodはこんな感じで生成されました。
ぱっと見あまり自分では一から書きたくない雰囲気の内容ですが、出してもらったものを手直しするのは楽ちんでした。
、Modalコンポーネントの `overlay*` / `transition*` 系プロパティの階層化用のcodemod
const PROP_MAP = {
    overlay: {
        overlayBlur: { key: "blur" },
        overlayColor: { key: "color" },
        overlayOpacity: { key: "opacity" },
    },
    transition: {
        transition: { key: "transition" },
        transitionDuration: { key: "duration" },
        exitTransitionDuration: { key: "exitDuration" },
        transitionTimingFunction: { key: "timingFunction" },
    },
};
module.exports = function transform(file, api) {
    const j = api.jscodeshift;
    const root = j(file.source);
    // Resolve local name of Modal imported from '@mantine/core'
    const modalLocalNames = new Set();
    root.find(j.ImportDeclaration, { source: { value: "@mantine/core" } }).forEach((path) => {
        path.value.specifiers.forEach((spec) => {
            if (spec.type === "ImportSpecifier" && spec.imported && spec.imported.name === "Modal") {
                modalLocalNames.add(spec.local ? spec.local.name : "Modal");
            }
        });
    });
    if (modalLocalNames.size === 0) {
        return file.source; // Nothing to do
    }
    // Helpers
    const isNamedElement = (el, names) =>
        el.name &&
        ((el.name.type === "JSXIdentifier" && names.has(el.name.name)) ||
            (el.name.type === "JSXMemberExpression" && names.has(el.name.property.name)));
    const getJSXAttr = (opening, name) =>
        opening.attributes.find((a) => a.type === "JSXAttribute" && a.name && a.name.name === name);
    const removeJSXAttr = (opening, name) => {
        opening.attributes = opening.attributes.filter(
            (a) => !(a.type === "JSXAttribute" && a.name && a.name.name === name),
        );
    };
    const ensureObjectProp = (opening, propName) => {
        let attr = getJSXAttr(opening, propName);
        if (!attr) {
            // create empty object literal: propName={{}}
            const emptyObj = j.objectExpression([]);
            attr = j.jsxAttribute(j.jsxIdentifier(propName), j.jsxExpressionContainer(emptyObj));
            opening.attributes.push(attr);
            return { attr, obj: emptyObj, created: true };
        }
        // If no value or non-expression, bail out
        if (!attr.value || attr.value.type !== "JSXExpressionContainer") {
            return { attr, obj: null, created: false };
        }
        const expr = attr.value.expression;
        if (expr.type !== "ObjectExpression") {
            return { attr, obj: null, created: false };
        }
        return { attr, obj: expr, created: false };
    };
    const addOrReplaceKey = (objExpr, key, valueExpr) => {
        const existing = objExpr.properties.find(
            (p) =>
                p.type === "ObjectProperty" &&
                ((p.key.type === "Identifier" && p.key.name === key) ||
                    (p.key.type === "StringLiteral" && p.key.value === key)),
        );
        if (existing) {
            existing.value = valueExpr;
        } else {
            objExpr.properties.push(j.objectProperty(j.identifier(key), valueExpr));
        }
    };
    // Process <Modal ...>
    root
        .find(j.JSXOpeningElement)
        .filter((path) => isNamedElement(path.value, modalLocalNames))
        .forEach((path) => {
            const opening = path.value;
            // Collect old props (if present)
            const overlayEntries = [];
            Object.keys(PROP_MAP.overlay).forEach((oldName) => {
                const attr = getJSXAttr(opening, oldName);
                if (attr && attr.value) {
                    overlayEntries.push({
                        oldName,
                        valueNode: attr.value,
                    });
                }
            });
            const transitionEntries = [];
            Object.keys(PROP_MAP.transition).forEach((oldName) => {
                const attr = getJSXAttr(opening, oldName);
                if (attr && attr.value) {
                    transitionEntries.push({
                        oldName,
                        valueNode: attr.value,
                    });
                }
            });
            // Migrate overlay*
            if (overlayEntries.length > 0) {
                const { obj: overlayObj } = ensureObjectProp(opening, "overlayProps");
                if (overlayObj) {
                    overlayEntries.forEach(({ oldName, valueNode }) => {
                        // Extract expression inside JSX attribute
                        const expr = valueNode.type === "JSXExpressionContainer" ? valueNode.expression : valueNode; // string literal case
                        addOrReplaceKey(overlayObj, PROP_MAP.overlay[oldName].key, expr);
                        removeJSXAttr(opening, oldName);
                    });
                } else {
                    // overlayProps existed but not an object literal – remove old props & annotate
                    overlayEntries.forEach(({ oldName }) => removeJSXAttr(opening, oldName));
                    opening.attributes.push(
                        j.jsxAttribute(
                            j.jsxIdentifier("data-codemod-note"),
                            j.stringLiteral("overlayProps not object literal; manual merge needed"),
                        ),
                    );
                }
            }
            // Migrate transition*
            if (transitionEntries.length > 0) {
                const { obj: transitionObj } = ensureObjectProp(opening, "transitionProps");
                if (transitionObj) {
                    transitionEntries.forEach(({ oldName, valueNode }) => {
                        const expr = valueNode.type === "JSXExpressionContainer" ? valueNode.expression : valueNode;
                        addOrReplaceKey(transitionObj, PROP_MAP.transition[oldName].key, expr);
                        removeJSXAttr(opening, oldName);
                    });
                } else {
                    transitionEntries.forEach(({ oldName }) => removeJSXAttr(opening, oldName));
                    opening.attributes.push(
                        j.jsxAttribute(
                            j.jsxIdentifier("data-codemod-note"),
                            j.stringLiteral("transitionProps not object literal; manual merge needed"),
                        ),
                    );
                }
            }
        });
    return root.toSource({ quote: "single", reuseWhitespace: false });
};
このcodemodを生成する際に使ったプロンプトは以下のような内容です。
Modalコンポーネントの overlay* / transition* 系プロパティの階層化を
行うjscodeshiftベースのcodemodを作成してください。
上記のcodemodを適用すると、以下のようなビフォーアフターになります。
// Before
<Modal overlayOpacity={0.5} transitionDuration={200} />
// After (codemod適用後)
<Modal
  overlayProps={{ opacity: 0.5 }}
  transitionProps={{ duration: 200 }}
/>
Step 3: 段階的適用とコミット
生成されたcodemodを一つずつ適用していきます。個人的にはここが重要と感じました。
実際に適用してみて、ヌケモレがあればAIに修正してもらうか、自明なら自分でcodemodを修正して再度適用し、あらためて確認して問題なければコミット。
この流れを繰り返すことで、膨大なビルドエラーに対して安全に段階的にアップグレードを進められました。
私は手で適用していったのですが、以下のようなコマンドで適用していきました。
npx jscodeshift -t ./codemods/01_modal_props.js ./src --extensions=tsx --parser=tsx
まとめ
作業時間的に、手動でやったら何日もかかりそうだなーと思って気の滅入る感じでしたが、AIに作ってもらったcodemodを活用することでv5->v8のアップグレードが数時間で完了しました。
クオリティ的にも手動でやると絶対にヌケモレがあったりtypoしたりすると思いますが、それもなくせたのは大きかったです。
こまめにコミットすることで、いつでもロールバックできる安心感もありましたし、codemodを分割することでレビューもしやすかったです。
今回のMantineアップグレードのような、影響範囲の大きいリファクタリングをする際には有効なアプローチと思いました。
同じような課題を抱えている方は、ぜひこのアプローチを試してみてください。きっと、想像以上にスムーズに進められるはずです🚀
Discussion