From 918af60940a4b3f2461d6578e8462efc20961ab0 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 18:29:40 -0500 Subject: [PATCH 01/59] feat: add css-plugins-to-native-css codemod --- README.md | 6 + codemods/css-plugins-to-native-css/README.md | 58 +++++ .../css-plugins-to-native-css/codemod.yaml | 23 ++ .../css-plugins-to-native-css/package.json | 22 ++ .../css-plugins-to-native-css/src/workflow.ts | 239 ++++++++++++++++++ .../tests/expected/no-css.config.js | 5 + .../tests/expected/sass.config.js | 10 + .../tests/expected/webpack.config.js | 27 ++ .../tests/expected/webpack.config.mjs | 15 ++ .../tests/input/no-css.config.js | 5 + .../tests/input/sass.config.js | 10 + .../tests/input/webpack.config.js | 26 ++ .../tests/input/webpack.config.mjs | 14 + .../css-plugins-to-native-css/workflow.yaml | 27 ++ package-lock.json | 19 ++ 15 files changed, 506 insertions(+) create mode 100644 codemods/css-plugins-to-native-css/README.md create mode 100644 codemods/css-plugins-to-native-css/codemod.yaml create mode 100644 codemods/css-plugins-to-native-css/package.json create mode 100644 codemods/css-plugins-to-native-css/src/workflow.ts create mode 100644 codemods/css-plugins-to-native-css/tests/expected/no-css.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/expected/sass.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/expected/webpack.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs create mode 100644 codemods/css-plugins-to-native-css/tests/input/no-css.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/sass.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/webpack.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/webpack.config.mjs create mode 100644 codemods/css-plugins-to-native-css/workflow.yaml diff --git a/README.md b/README.md index b8b29ea..cc11826 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ Run a codemod on your project with the [Codemod CLI](https://docs.codemod.com/cl npx codemod@latest run @webpack/ ``` +## Codemods + +| Codemod | Description | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| [`css-plugins-to-native-css`](codemods/css-plugins-to-native-css) | Migrate `mini-css-extract-plugin` and `style-loader`/`css-loader` rules to webpack's native CSS support (`experiments.css`). | + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add a new codemod or improve an existing one. diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md new file mode 100644 index 0000000..4363d30 --- /dev/null +++ b/codemods/css-plugins-to-native-css/README.md @@ -0,0 +1,58 @@ +# @webpack/css-plugins-to-native-css + +Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader`/`css-loader` rules to webpack's [native CSS support](https://webpack.js.org/configuration/experiments/#experimentscss) (`experiments.css`). + +## What it does + +- Replaces `use` arrays made up of `style-loader`, `css-loader`, and/or `MiniCssExtractPlugin.loader` with `type: "css/auto"`. +- Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty). +- Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. +- Enables `experiments.css: true` on the affected configuration. + +Rules that still need a preprocessor (`sass-loader`, `less-loader`, `stylus-loader`, `postcss-loader`, …) are left untouched. + +## Usage + +```sh +npx codemod@latest run @webpack/css-plugins-to-native-css +``` + +## Example + +Before: + +```js +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; +``` + +After: + +```js +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/i, + type: "css/auto", + }, + ], + }, +}; +``` + +Remember to also remove `mini-css-extract-plugin`, `style-loader`, and `css-loader` from your `package.json` if nothing else uses them. diff --git a/codemods/css-plugins-to-native-css/codemod.yaml b/codemods/css-plugins-to-native-css/codemod.yaml new file mode 100644 index 0000000..1f56e8e --- /dev/null +++ b/codemods/css-plugins-to-native-css/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@webpack/css-plugins-to-native-css" +version: "1.0.0" +description: Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css) +author: bjohansebas (Sebastian Beltran) +license: MIT +workflow: workflow.yaml +repository: "https://github.com/webpack/codemods/tree/HEAD/codemods/css-plugins-to-native-css" +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - webpack + +registry: + access: public + visibility: public diff --git a/codemods/css-plugins-to-native-css/package.json b/codemods/css-plugins-to-native-css/package.json new file mode 100644 index 0000000..b646175 --- /dev/null +++ b/codemods/css-plugins-to-native-css/package.json @@ -0,0 +1,22 @@ +{ + "name": "@webpack/css-plugins-to-native-css", + "private": true, + "version": "1.0.0", + "description": "Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css).", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/webpack/codemods.git", + "directory": "codemods/css-plugins-to-native-css", + "bugs": "https://github.com/webpack/codemods/issues" + }, + "author": "bjohansebas (Sebastian Beltran)", + "license": "MIT", + "homepage": "https://github.com/webpack/codemods/blob/main/codemods/css-plugins-to-native-css/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.2" + } +} diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts new file mode 100644 index 0000000..921e617 --- /dev/null +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -0,0 +1,239 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { Edit, SgNode, SgRoot } from "@codemod.com/jssg-types/main"; + +const PLUGIN_MODULE = "mini-css-extract-plugin"; +const REMOVABLE_LOADERS = new Set(["style-loader", "css-loader"]); +// Rules whose `test` targets a preprocessor still need their loaders — skip them. +const PREPROCESSOR_TEST = /s[ac]ss|less|styl/; + +interface Range { + start: number; + end: number; +} + +function rangeOf(node: SgNode): Range { + const range = node.range(); + return { start: range.start.index, end: range.end.index }; +} + +function unquote(text: string): string { + return text.replace(/^["'`]/, "").replace(/["'`]$/, ""); +} + +function namedChildren(node: SgNode): SgNode[] { + return node.children().filter((child) => child.isNamed()); +} + +function keyName(pair: SgNode): string | null { + const key = pair.field("key"); + return key ? unquote(key.text()) : null; +} + +function pairsOf(objectNode: SgNode): SgNode[] { + return namedChildren(objectNode).filter((child) => child.kind() === "pair"); +} + +function findPair(objectNode: SgNode, name: string): SgNode | undefined { + return pairsOf(objectNode).find((pair) => keyName(pair) === name); +} + +// Whitespace at the start of the line containing `index`. +function lineIndent(source: string, index: number): string { + const lineStart = source.lastIndexOf("\n", index - 1) + 1; + const match = /^[ \t]*/.exec(source.slice(lineStart, index)); + return match ? match[0] : ""; +} + +function isInsideAny(range: Range, ranges: Range[]): boolean { + return ranges.some((outer) => range.start >= outer.start && range.end <= outer.end); +} + +async function transform(root: SgRoot): Promise { + const rootNode = root.root(); + const source = rootNode.text(); + + // Bindings introduced by `require`/`import` of mini-css-extract-plugin. + const pluginBindings: { name: string; statement: SgNode }[] = []; + const importPatterns = [ + "const $NAME = require($SOURCE)", + "let $NAME = require($SOURCE)", + "var $NAME = require($SOURCE)", + "import $NAME from $SOURCE", + ]; + for (const pattern of importPatterns) { + for (const statement of rootNode.findAll({ rule: { pattern } })) { + const name = statement.getMatch("NAME"); + const moduleSource = statement.getMatch("SOURCE"); + if (!name || !moduleSource) continue; + if (unquote(moduleSource.text()) !== PLUGIN_MODULE) continue; + pluginBindings.push({ name: name.text(), statement }); + } + } + const pluginNames = new Set(pluginBindings.map((binding) => binding.name)); + + // `MiniCssExtractPlugin.loader` or `require("mini-css-extract-plugin").loader`. + const isPluginLoaderExpression = (node: SgNode): boolean => { + if (node.kind() !== "member_expression") return false; + const objectPart = node.field("object"); + const propertyPart = node.field("property"); + if (!objectPart || !propertyPart || propertyPart.text() !== "loader") return false; + if (objectPart.kind() === "identifier") return pluginNames.has(objectPart.text()); + return ( + objectPart.kind() === "call_expression" && + /^require\(\s*["'`]mini-css-extract-plugin["'`]\s*\)$/.test(objectPart.text()) + ); + }; + + const isRemovableLoaderValue = (node: SgNode): boolean => { + if (node.kind() === "string") return REMOVABLE_LOADERS.has(unquote(node.text())); + return isPluginLoaderExpression(node); + }; + + // A `use` array entry replaceable by native CSS: a known loader string, + // the plugin's `.loader`, or `{ loader: , ... }`. + const isRemovableUseElement = (node: SgNode): boolean => { + if (isRemovableLoaderValue(node)) return true; + if (node.kind() !== "object") return false; + const loaderPair = findPair(node, "loader"); + if (!loaderPair) return false; + const loaderValue = loaderPair.field("value"); + return loaderValue ? isRemovableLoaderValue(loaderValue) : false; + }; + + const edits: Edit[] = []; + const editedRanges: Range[] = []; + const configObjects: SgNode[] = []; + + const removeText = (range: Range): void => { + edits.push({ startPos: range.start, endPos: range.end, insertedText: "" }); + editedRanges.push(range); + }; + + // Removal range for one element of a comma-separated list (array/object). + const listItemRemovalRange = (node: SgNode): Range => { + const parent = node.parent(); + const range = rangeOf(node); + if (!parent) return range; + const siblings = namedChildren(parent); + const index = siblings.findIndex((sibling) => sibling.range().start.index === range.start); + const next = siblings[index + 1]; + if (next) return { start: range.start, end: next.range().start.index }; + const previous = siblings[index - 1]; + if (previous) return { start: previous.range().end.index, end: range.end }; + return range; + }; + + // The enclosing webpack config object: nearest ancestor holding a `module` pair. + const findConfigForRule = (node: SgNode): SgNode | null => { + let current = node.parent(); + while (current) { + if (current.kind() === "pair" && keyName(current) === "module") { + const parent = current.parent(); + if (parent && parent.kind() === "object") return parent; + } + current = current.parent(); + } + return null; + }; + + for (const pair of rootNode.findAll({ rule: { kind: "pair" } })) { + if (keyName(pair) !== "use") continue; + const value = pair.field("value"); + if (!value) continue; + const elements = value.kind() === "array" ? namedChildren(value) : [value]; + if (!elements.length || !elements.every(isRemovableUseElement)) continue; + const ruleObject = pair.parent(); + if (!ruleObject || ruleObject.kind() !== "object") continue; + const testValue = findPair(ruleObject, "test")?.field("value"); + if (testValue && PREPROCESSOR_TEST.test(testValue.text())) continue; + edits.push(pair.replace('type: "css/auto"')); + editedRanges.push(rangeOf(pair)); + const config = findConfigForRule(pair); + if (config) configObjects.push(config); + } + + for (const pair of rootNode.findAll({ rule: { kind: "pair" } })) { + if (keyName(pair) !== "plugins") continue; + const value = pair.field("value"); + if (!value || value.kind() !== "array") continue; + const elements = namedChildren(value); + const removed = elements.filter((element) => { + if (element.kind() !== "new_expression") return false; + const constructorNode = element.field("constructor"); + return constructorNode !== null && pluginNames.has(constructorNode.text()); + }); + if (!removed.length) continue; + if (removed.length === elements.length) { + removeText(listItemRemovalRange(pair)); + } else { + for (const element of removed) removeText(listItemRemovalRange(element)); + } + const parent = pair.parent(); + if (parent && parent.kind() === "object") configObjects.push(parent); + } + + if (!edits.length) return null; + + // Drop the plugin import once no reference survives outside the edited ranges. + for (const binding of pluginBindings) { + const statementRange = rangeOf(binding.statement); + const survivingReference = rootNode + .findAll({ rule: { kind: "identifier" } }) + .some((identifier) => { + if (identifier.text() !== binding.name) return false; + const range = rangeOf(identifier); + if (range.start >= statementRange.start && range.end <= statementRange.end) return false; + return !isInsideAny(range, editedRanges); + }); + if (survivingReference) continue; + let end = statementRange.end; + if (source[end] === "\r") end += 1; + if (source[end] === "\n") end += 1; + edits.push({ startPos: statementRange.start, endPos: end, insertedText: "" }); + } + + // Insert a property right after an object's opening brace, matching its layout. + const insertIntoObject = ( + objectNode: SgNode, + buildProperty: (indent: string, indentUnit: string) => string, + ): void => { + const range = rangeOf(objectNode); + const insertAt = range.start + 1; + const properties = namedChildren(objectNode); + const multiline = objectNode.text().includes("\n") && properties.length > 0; + let insertedText: string; + if (multiline) { + const indent = lineIndent(source, properties[0].range().start.index); + const indentUnit = indent.includes("\t") ? "\t" : indent || " "; + insertedText = `\n${indent}${buildProperty(indent, indentUnit)},`; + } else if (properties.length) { + insertedText = ` ${buildProperty("", "")},`; + } else { + insertedText = ` ${buildProperty("", "")} `; + } + edits.push({ startPos: insertAt, endPos: insertAt, insertedText }); + }; + + const seenConfigs = new Set(); + for (const config of configObjects) { + const start = config.range().start.index; + if (seenConfigs.has(start)) continue; + seenConfigs.add(start); + const experimentsValue = findPair(config, "experiments")?.field("value"); + if (experimentsValue) { + if (experimentsValue.kind() !== "object") continue; + if (findPair(experimentsValue, "css")) continue; + insertIntoObject(experimentsValue, () => "css: true"); + } else { + insertIntoObject(config, (indent, indentUnit) => + indent || indentUnit + ? `experiments: {\n${indent}${indentUnit}css: true,\n${indent}}` + : "experiments: { css: true }", + ); + } + } + + return rootNode.commitEdits(edits); +} + +export default transform; diff --git a/codemods/css-plugins-to-native-css/tests/expected/no-css.config.js b/codemods/css-plugins-to-native-css/tests/expected/no-css.config.js new file mode 100644 index 0000000..2b5ae71 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/no-css.config.js @@ -0,0 +1,5 @@ +module.exports = { + module: { + rules: [{ test: /\.js$/, use: ["babel-loader"] }], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/expected/sass.config.js b/codemods/css-plugins-to-native-css/tests/expected/sass.config.js new file mode 100644 index 0000000..3133523 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/sass.config.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.scss$/, + use: ["style-loader", "css-loader", "sass-loader"], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js new file mode 100644 index 0000000..e0d92ae --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js @@ -0,0 +1,27 @@ +const path = require("path"); +const { DefinePlugin } = require("webpack"); + +module.exports = { + experiments: { + css: true, + }, + entry: "./src/index.js", + output: { + path: path.resolve(__dirname, "dist"), + }, + module: { + rules: [ + { + test: /\.css$/i, + type: "css/auto", + }, + { + test: /\.js$/, + use: ["babel-loader"], + }, + ], + }, + plugins: [ + new DefinePlugin({ DEBUG: "false" }), + ], +}; diff --git a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs new file mode 100644 index 0000000..a95c12f --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs @@ -0,0 +1,15 @@ +export default { + entry: "./src/index.js", + module: { + rules: [ + { + test: /\.css$/, + type: "css/auto", + }, + ], + }, + experiments: { + css: true, + outputModule: true, + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/no-css.config.js b/codemods/css-plugins-to-native-css/tests/input/no-css.config.js new file mode 100644 index 0000000..2b5ae71 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/no-css.config.js @@ -0,0 +1,5 @@ +module.exports = { + module: { + rules: [{ test: /\.js$/, use: ["babel-loader"] }], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/sass.config.js b/codemods/css-plugins-to-native-css/tests/input/sass.config.js new file mode 100644 index 0000000..3133523 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/sass.config.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.scss$/, + use: ["style-loader", "css-loader", "sass-loader"], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/webpack.config.js b/codemods/css-plugins-to-native-css/tests/input/webpack.config.js new file mode 100644 index 0000000..c4bc556 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/webpack.config.js @@ -0,0 +1,26 @@ +const path = require("path"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const { DefinePlugin } = require("webpack"); + +module.exports = { + entry: "./src/index.js", + output: { + path: path.resolve(__dirname, "dist"), + }, + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + { + test: /\.js$/, + use: ["babel-loader"], + }, + ], + }, + plugins: [ + new MiniCssExtractPlugin({ filename: "[name].css" }), + new DefinePlugin({ DEBUG: "false" }), + ], +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/webpack.config.mjs b/codemods/css-plugins-to-native-css/tests/input/webpack.config.mjs new file mode 100644 index 0000000..4ddcc8e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/webpack.config.mjs @@ -0,0 +1,14 @@ +export default { + entry: "./src/index.js", + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { importLoaders: 1 } }], + }, + ], + }, + experiments: { + outputModule: true, + }, +}; diff --git a/codemods/css-plugins-to-native-css/workflow.yaml b/codemods/css-plugins-to-native-css/workflow.yaml new file mode 100644 index 0000000..f920641 --- /dev/null +++ b/codemods/css-plugins-to-native-css/workflow.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css) + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript diff --git a/package-lock.json b/package-lock.json index ccdfb57..f75d81a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,14 @@ "typescript-eslint": "^8.39.0" } }, + "codemods/css-plugins-to-native-css": { + "name": "@webpack/css-plugins-to-native-css", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.2" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -292,6 +300,13 @@ "prettier": "^2.7.1" } }, + "node_modules/@codemod.com/jssg-types": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@codemod.com/jssg-types/-/jssg-types-1.6.3.tgz", + "integrity": "sha512-b5829ixO5TdGL5xg5YcL9YfiLC3WwFZU+8Zx78NpsHlJ1iySbqfA0g0psqNXv8ss/r7hdpy2KiBXm3GE6bN5bQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -996,6 +1011,10 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@webpack/css-plugins-to-native-css": { + "resolved": "codemods/css-plugins-to-native-css", + "link": true + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", From 1a36402152914babbe5339b5b6754920ac3b37d2 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 18:35:57 -0500 Subject: [PATCH 02/59] refactor: rely on experiments.css auto default and drop trivial css rules --- codemods/css-plugins-to-native-css/README.md | 11 +- .../css-plugins-to-native-css/src/workflow.ts | 146 +++++++++++++++--- .../tests/expected/css-only.config.js | 1 + .../tests/expected/keep-rule.config.js | 16 ++ .../tests/expected/webpack.config.js | 7 - .../tests/expected/webpack.config.mjs | 9 -- .../tests/input/css-only.config.js | 13 ++ .../tests/input/keep-rule.config.js | 13 ++ 8 files changed, 179 insertions(+), 37 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/css-only.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/expected/keep-rule.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/css-only.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/keep-rule.config.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 4363d30..dd481b9 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -4,10 +4,10 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader ## What it does -- Replaces `use` arrays made up of `style-loader`, `css-loader`, and/or `MiniCssExtractPlugin.loader` with `type: "css/auto"`. +- Removes rules that only wire up `style-loader`, `css-loader`, and/or `MiniCssExtractPlugin.loader` (cascading to empty `rules`/`module` entries): with no user rule matching `.css`, webpack's `experiments.css: "auto"` default enables native CSS by itself, so no explicit option is needed. +- Rules with extra conditions (e.g. `include`) are kept with `type: "css/auto"` instead — and since their presence disables the `"auto"` default, `experiments.css: true` is added to that configuration. - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty). - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. -- Enables `experiments.css: true` on the affected configuration. Rules that still need a preprocessor (`sass-loader`, `less-loader`, `stylus-loader`, `postcss-loader`, …) are left untouched. @@ -39,6 +39,12 @@ module.exports = { After: +```js +module.exports = {}; +``` + +Native CSS handles `.css` (and `.module.css`) files out of the box. When a rule carries extra conditions it is preserved instead: + ```js module.exports = { experiments: { @@ -48,6 +54,7 @@ module.exports = { rules: [ { test: /\.css$/i, + include: path.resolve(__dirname, "src"), type: "css/auto", }, ], diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 921e617..cc0b824 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -109,18 +109,60 @@ async function transform(root: SgRoot): Promise { editedRanges.push(range); }; - // Removal range for one element of a comma-separated list (array/object). - const listItemRemovalRange = (node: SgNode): Range => { + // Removals of comma-separated list items are grouped per parent container so + // sibling removals in the same object/array never produce overlapping ranges. + const pendingRemovals = new Map; removed: Set }>(); + + const markForRemoval = (node: SgNode): void => { const parent = node.parent(); - const range = rangeOf(node); - if (!parent) return range; - const siblings = namedChildren(parent); - const index = siblings.findIndex((sibling) => sibling.range().start.index === range.start); - const next = siblings[index + 1]; - if (next) return { start: range.start, end: next.range().start.index }; - const previous = siblings[index - 1]; - if (previous) return { start: previous.range().end.index, end: range.end }; - return range; + if (!parent) return; + const key = parent.range().start.index; + let group = pendingRemovals.get(key); + if (!group) { + group = { parent, removed: new Set() }; + pendingRemovals.set(key, group); + } + group.removed.add(node.range().start.index); + }; + + const finalizeRemovals = (): void => { + for (const { parent, removed } of pendingRemovals.values()) { + const children = namedChildren(parent); + if (children.every((child) => removed.has(child.range().start.index))) { + edits.push(parent.replace(parent.kind() === "array" ? "[]" : "{}")); + editedRanges.push(rangeOf(parent)); + continue; + } + // Delete each contiguous run of removed children up to the next kept + // sibling, or back to the previous kept one for a trailing run. + let index = 0; + while (index < children.length) { + if (!removed.has(children[index].range().start.index)) { + index += 1; + continue; + } + let runEnd = index; + while ( + runEnd + 1 < children.length && + removed.has(children[runEnd + 1].range().start.index) + ) { + runEnd += 1; + } + const next = children[runEnd + 1]; + if (next) { + removeText({ + start: children[index].range().start.index, + end: next.range().start.index, + }); + } else { + removeText({ + start: children[index - 1].range().end.index, + end: children[runEnd].range().end.index, + }); + } + index = runEnd + 1; + } + } }; // The enclosing webpack config object: nearest ancestor holding a `module` pair. @@ -136,6 +178,17 @@ async function transform(root: SgRoot): Promise { return null; }; + // Rules holding only `test` + `use` can be dropped outright: with no user rule + // matching `.css`, `experiments.css: "auto"` enables native CSS by itself. + // Rules with extra conditions must stay, which disables the "auto" default — + // only those configs need an explicit `experiments.css: true`. + interface RulesArrayWork { + arrayNode: SgNode; + removedElements: SgNode[]; + swappedUsePairs: SgNode[]; + } + const rulesWork = new Map(); + for (const pair of rootNode.findAll({ rule: { kind: "pair" } })) { if (keyName(pair) !== "use") continue; const value = pair.field("value"); @@ -146,10 +199,61 @@ async function transform(root: SgRoot): Promise { if (!ruleObject || ruleObject.kind() !== "object") continue; const testValue = findPair(ruleObject, "test")?.field("value"); if (testValue && PREPROCESSOR_TEST.test(testValue.text())) continue; - edits.push(pair.replace('type: "css/auto"')); - editedRanges.push(rangeOf(pair)); - const config = findConfigForRule(pair); - if (config) configObjects.push(config); + const arrayNode = ruleObject.parent(); + const key = arrayNode ? arrayNode.range().start.index : rangeOf(pair).start; + let work = rulesWork.get(key); + if (!work && arrayNode) { + work = { arrayNode, removedElements: [], swappedUsePairs: [] }; + rulesWork.set(key, work); + } + if (!work) continue; + const trivialRule = pairsOf(ruleObject).every((rulePair) => { + const name = keyName(rulePair); + return name === "test" || name === "use"; + }); + if (trivialRule && arrayNode && arrayNode.kind() === "array") { + work.removedElements.push(ruleObject); + } else { + work.swappedUsePairs.push(pair); + } + } + + for (const work of rulesWork.values()) { + const allElements = namedChildren(work.arrayNode); + if ( + work.removedElements.length === allElements.length && + !work.swappedUsePairs.length + ) { + // The whole rules array goes away — cascade to `rules`/`module` when empty. + let removalTarget: SgNode = work.arrayNode; + const rulesPair = work.arrayNode.parent(); + if (rulesPair && rulesPair.kind() === "pair") { + removalTarget = rulesPair; + const moduleObject = rulesPair.parent(); + const modulePair = moduleObject ? moduleObject.parent() : null; + if ( + moduleObject && + moduleObject.kind() === "object" && + pairsOf(moduleObject).length === 1 && + modulePair && + modulePair.kind() === "pair" && + keyName(modulePair) === "module" + ) { + removalTarget = modulePair; + } + } + markForRemoval(removalTarget); + continue; + } + for (const element of work.removedElements) { + markForRemoval(element); + } + for (const pair of work.swappedUsePairs) { + edits.push(pair.replace('type: "css/auto"')); + editedRanges.push(rangeOf(pair)); + const config = findConfigForRule(pair); + if (config) configObjects.push(config); + } } for (const pair of rootNode.findAll({ rule: { kind: "pair" } })) { @@ -164,14 +268,14 @@ async function transform(root: SgRoot): Promise { }); if (!removed.length) continue; if (removed.length === elements.length) { - removeText(listItemRemovalRange(pair)); + markForRemoval(pair); } else { - for (const element of removed) removeText(listItemRemovalRange(element)); + for (const element of removed) markForRemoval(element); } - const parent = pair.parent(); - if (parent && parent.kind() === "object") configObjects.push(parent); } + finalizeRemovals(); + if (!edits.length) return null; // Drop the plugin import once no reference survives outside the edited ranges. @@ -189,6 +293,10 @@ async function transform(root: SgRoot): Promise { let end = statementRange.end; if (source[end] === "\r") end += 1; if (source[end] === "\n") end += 1; + // At the top of the file also swallow the blank line that separated it. + while (statementRange.start === 0 && (source[end] === "\n" || source[end] === "\r")) { + end += 1; + } edits.push({ startPos: statementRange.start, endPos: end, insertedText: "" }); } diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-only.config.js b/codemods/css-plugins-to-native-css/tests/expected/css-only.config.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/css-only.config.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/codemods/css-plugins-to-native-css/tests/expected/keep-rule.config.js b/codemods/css-plugins-to-native-css/tests/expected/keep-rule.config.js new file mode 100644 index 0000000..b457631 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/keep-rule.config.js @@ -0,0 +1,16 @@ +const path = require("path"); + +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: path.resolve(__dirname, "src"), + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js index e0d92ae..dc58e99 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js @@ -2,19 +2,12 @@ const path = require("path"); const { DefinePlugin } = require("webpack"); module.exports = { - experiments: { - css: true, - }, entry: "./src/index.js", output: { path: path.resolve(__dirname, "dist"), }, module: { rules: [ - { - test: /\.css$/i, - type: "css/auto", - }, { test: /\.js$/, use: ["babel-loader"], diff --git a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs index a95c12f..df49cf8 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs +++ b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs @@ -1,15 +1,6 @@ export default { entry: "./src/index.js", - module: { - rules: [ - { - test: /\.css$/, - type: "css/auto", - }, - ], - }, experiments: { - css: true, outputModule: true, }, }; diff --git a/codemods/css-plugins-to-native-css/tests/input/css-only.config.js b/codemods/css-plugins-to-native-css/tests/input/css-only.config.js new file mode 100644 index 0000000..c500be9 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/css-only.config.js @@ -0,0 +1,13 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "[name].css" })], +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/keep-rule.config.js b/codemods/css-plugins-to-native-css/tests/input/keep-rule.config.js new file mode 100644 index 0000000..e742231 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/keep-rule.config.js @@ -0,0 +1,13 @@ +const path = require("path"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + include: path.resolve(__dirname, "src"), + use: ["style-loader", "css-loader"], + }, + ], + }, +}; From f4799e11ead1ab0424300f4254fb36749d319bf6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 18:42:57 -0500 Subject: [PATCH 03/59] feat: keep remaining loaders in front of native css instead of skipping the rule --- codemods/css-plugins-to-native-css/README.md | 21 ++++- .../css-plugins-to-native-css/src/workflow.ts | 91 +++++++++++++------ .../tests/expected/sass.config.js | 3 +- .../tests/expected/unknown-loader.config.js | 14 +++ .../tests/input/unknown-loader.config.js | 10 ++ 5 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/unknown-loader.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/unknown-loader.config.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index dd481b9..0d9c489 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -6,11 +6,10 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes rules that only wire up `style-loader`, `css-loader`, and/or `MiniCssExtractPlugin.loader` (cascading to empty `rules`/`module` entries): with no user rule matching `.css`, webpack's `experiments.css: "auto"` default enables native CSS by itself, so no explicit option is needed. - Rules with extra conditions (e.g. `include`) are kept with `type: "css/auto"` instead — and since their presence disables the `"auto"` default, `experiments.css: true` is added to that configuration. +- Any other loader in the chain (`sass-loader`, `less-loader`, `postcss-loader`, custom ones, …) keeps working in front of native CSS: it stays in `use` while the injection/extraction loaders are dropped, and the rule gets `type: "css/auto"`. - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty). - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. -Rules that still need a preprocessor (`sass-loader`, `less-loader`, `stylus-loader`, `postcss-loader`, …) are left untouched. - ## Usage ```sh @@ -43,7 +42,23 @@ After: module.exports = {}; ``` -Native CSS handles `.css` (and `.module.css`) files out of the box. When a rule carries extra conditions it is preserved instead: +Native CSS handles `.css` (and `.module.css`) files out of the box. Preprocessor rules keep their loader in front of it: + +```js +module.exports = { + module: { + rules: [ + { + test: /\.scss$/, + use: ["sass-loader"], + type: "css/auto", + }, + ], + }, +}; +``` + +When a rule matching `.css` carries extra conditions it is preserved too — and since its presence turns off the `experiments.css: "auto"` default, the option is set explicitly: ```js module.exports = { diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index cc0b824..119dde6 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -3,8 +3,6 @@ import type { Edit, SgNode, SgRoot } from "@codemod.com/jssg-types/main"; const PLUGIN_MODULE = "mini-css-extract-plugin"; const REMOVABLE_LOADERS = new Set(["style-loader", "css-loader"]); -// Rules whose `test` targets a preprocessor still need their loaders — skip them. -const PREPROCESSOR_TEST = /s[ac]ss|less|styl/; interface Range { start: number; @@ -84,20 +82,41 @@ async function transform(root: SgRoot): Promise { ); }; - const isRemovableLoaderValue = (node: SgNode): boolean => { - if (node.kind() === "string") return REMOVABLE_LOADERS.has(unquote(node.text())); - return isPluginLoaderExpression(node); + // Loader name behind a `use` entry: a plain string or `{ loader: "..." }`. + const loaderNameOf = (node: SgNode): string | null => { + if (node.kind() === "string") return unquote(node.text()); + if (node.kind() !== "object") return null; + const loaderValue = findPair(node, "loader")?.field("value"); + return loaderValue && loaderValue.kind() === "string" ? unquote(loaderValue.text()) : null; }; - // A `use` array entry replaceable by native CSS: a known loader string, - // the plugin's `.loader`, or `{ loader: , ... }`. + // A `use` entry replaceable by native CSS: a known loader string, the + // plugin's `.loader`, or `{ loader: , ... }`. const isRemovableUseElement = (node: SgNode): boolean => { - if (isRemovableLoaderValue(node)) return true; - if (node.kind() !== "object") return false; - const loaderPair = findPair(node, "loader"); - if (!loaderPair) return false; - const loaderValue = loaderPair.field("value"); - return loaderValue ? isRemovableLoaderValue(loaderValue) : false; + if (isPluginLoaderExpression(node)) return true; + if (node.kind() === "object") { + const loaderValue = findPair(node, "loader")?.field("value"); + if (loaderValue && isPluginLoaderExpression(loaderValue)) return true; + } + const name = loaderNameOf(node); + return name !== null && REMOVABLE_LOADERS.has(name); + }; + + // Whether the rule still claims plain `.css` resources after the transform — + // if so it disables the `experiments.css: "auto"` default, which then needs + // an explicit `true`. Unreadable conditions count as matching, to be safe. + const ruleMatchesCssFiles = (ruleObject: SgNode): boolean => { + const testValue = findPair(ruleObject, "test")?.field("value"); + if (!testValue) return true; + if (testValue.kind() !== "regex") return true; + const literal = /^\/(.*)\/([a-z]*)$/s.exec(testValue.text()); + if (!literal) return true; + try { + const regex = new RegExp(literal[1], literal[2]); + return regex.test("/file.css") || regex.test("/file.module.css"); + } catch { + return true; + } }; const edits: Edit[] = []; @@ -182,10 +201,15 @@ async function transform(root: SgRoot): Promise { // matching `.css`, `experiments.css: "auto"` enables native CSS by itself. // Rules with extra conditions must stay, which disables the "auto" default — // only those configs need an explicit `experiments.css: true`. + interface UseSwap { + pair: SgNode; + ruleObject: SgNode; + keptLoaders: SgNode[]; + } interface RulesArrayWork { arrayNode: SgNode; removedElements: SgNode[]; - swappedUsePairs: SgNode[]; + swaps: UseSwap[]; } const rulesWork = new Map(); @@ -194,16 +218,18 @@ async function transform(root: SgRoot): Promise { const value = pair.field("value"); if (!value) continue; const elements = value.kind() === "array" ? namedChildren(value) : [value]; - if (!elements.length || !elements.every(isRemovableUseElement)) continue; + if (!elements.length) continue; + const removable = elements.filter(isRemovableUseElement); + // Any other loader (preprocessors, custom ones) stays in front of native CSS. + const kept = elements.filter((element) => !isRemovableUseElement(element)); + if (!removable.length) continue; const ruleObject = pair.parent(); if (!ruleObject || ruleObject.kind() !== "object") continue; - const testValue = findPair(ruleObject, "test")?.field("value"); - if (testValue && PREPROCESSOR_TEST.test(testValue.text())) continue; const arrayNode = ruleObject.parent(); const key = arrayNode ? arrayNode.range().start.index : rangeOf(pair).start; let work = rulesWork.get(key); if (!work && arrayNode) { - work = { arrayNode, removedElements: [], swappedUsePairs: [] }; + work = { arrayNode, removedElements: [], swaps: [] }; rulesWork.set(key, work); } if (!work) continue; @@ -211,19 +237,16 @@ async function transform(root: SgRoot): Promise { const name = keyName(rulePair); return name === "test" || name === "use"; }); - if (trivialRule && arrayNode && arrayNode.kind() === "array") { + if (trivialRule && !kept.length && arrayNode && arrayNode.kind() === "array") { work.removedElements.push(ruleObject); } else { - work.swappedUsePairs.push(pair); + work.swaps.push({ pair, ruleObject, keptLoaders: kept }); } } for (const work of rulesWork.values()) { const allElements = namedChildren(work.arrayNode); - if ( - work.removedElements.length === allElements.length && - !work.swappedUsePairs.length - ) { + if (work.removedElements.length === allElements.length && !work.swaps.length) { // The whole rules array goes away — cascade to `rules`/`module` when empty. let removalTarget: SgNode = work.arrayNode; const rulesPair = work.arrayNode.parent(); @@ -248,10 +271,22 @@ async function transform(root: SgRoot): Promise { for (const element of work.removedElements) { markForRemoval(element); } - for (const pair of work.swappedUsePairs) { - edits.push(pair.replace('type: "css/auto"')); - editedRanges.push(rangeOf(pair)); - const config = findConfigForRule(pair); + for (const swap of work.swaps) { + if (swap.keptLoaders.length) { + // Preprocessor loaders stay in `use`; native CSS parses their output. + const keptTexts = swap.keptLoaders.map((loader) => loader.text()); + const indent = lineIndent(source, swap.pair.range().start.index); + const separator = swap.ruleObject.text().includes("\n") ? `,\n${indent}` : ", "; + edits.push( + swap.pair.replace(`use: [${keptTexts.join(", ")}]${separator}type: "css/auto"`), + ); + } else { + edits.push(swap.pair.replace('type: "css/auto"')); + } + editedRanges.push(rangeOf(swap.pair)); + // A surviving rule that matches `.css` turns the "auto" default off. + if (!ruleMatchesCssFiles(swap.ruleObject)) continue; + const config = findConfigForRule(swap.pair); if (config) configObjects.push(config); } } diff --git a/codemods/css-plugins-to-native-css/tests/expected/sass.config.js b/codemods/css-plugins-to-native-css/tests/expected/sass.config.js index 3133523..dfe0c00 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/sass.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/sass.config.js @@ -3,7 +3,8 @@ module.exports = { rules: [ { test: /\.scss$/, - use: ["style-loader", "css-loader", "sass-loader"], + use: ["sass-loader"], + type: "css/auto", }, ], }, diff --git a/codemods/css-plugins-to-native-css/tests/expected/unknown-loader.config.js b/codemods/css-plugins-to-native-css/tests/expected/unknown-loader.config.js new file mode 100644 index 0000000..2cc8a3c --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/unknown-loader.config.js @@ -0,0 +1,14 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + use: ["my-custom-loader"], + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/unknown-loader.config.js b/codemods/css-plugins-to-native-css/tests/input/unknown-loader.config.js new file mode 100644 index 0000000..cd30e38 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/unknown-loader.config.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", "css-loader", "my-custom-loader"], + }, + ], + }, +}; From 90b0be90f1f2f1f33dda18c97f5d7d41d1fb5277 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 18:48:50 -0500 Subject: [PATCH 04/59] feat: migrate mini-css-extract-plugin filename options to output.cssFilename --- codemods/css-plugins-to-native-css/README.md | 4 +- .../css-plugins-to-native-css/src/workflow.ts | 146 ++++++++++++++---- .../tests/expected/css-only.config.js | 6 +- .../tests/expected/webpack.config.js | 2 + .../tests/input/webpack.config.js | 2 +- 5 files changed, 127 insertions(+), 33 deletions(-) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 0d9c489..9cdbe6b 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -7,9 +7,11 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes rules that only wire up `style-loader`, `css-loader`, and/or `MiniCssExtractPlugin.loader` (cascading to empty `rules`/`module` entries): with no user rule matching `.css`, webpack's `experiments.css: "auto"` default enables native CSS by itself, so no explicit option is needed. - Rules with extra conditions (e.g. `include`) are kept with `type: "css/auto"` instead — and since their presence disables the `"auto"` default, `experiments.css: true` is added to that configuration. - Any other loader in the chain (`sass-loader`, `less-loader`, `postcss-loader`, custom ones, …) keeps working in front of native CSS: it stays in `use` while the injection/extraction loaders are dropped, and the rule gets `type: "css/auto"`. -- Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty). +- Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. +The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. + ## Usage ```sh diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 119dde6..60d86f6 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -121,7 +121,23 @@ async function transform(root: SgRoot): Promise { const edits: Edit[] = []; const editedRanges: Range[] = []; - const configObjects: SgNode[] = []; + + // Per-config plan of properties to add once removals are known. + interface ConfigPlan { + config: SgNode; + needsExperimentsCss: boolean; + outputProps: { name: string; valueText: string }[]; + } + const configPlans = new Map(); + const planFor = (config: SgNode): ConfigPlan => { + const key = config.range().start.index; + let plan = configPlans.get(key); + if (!plan) { + plan = { config, needsExperimentsCss: false, outputProps: [] }; + configPlans.set(key, plan); + } + return plan; + }; const removeText = (range: Range): void => { edits.push({ startPos: range.start, endPos: range.end, insertedText: "" }); @@ -144,12 +160,22 @@ async function transform(root: SgRoot): Promise { group.removed.add(node.range().start.index); }; - const finalizeRemovals = (): void => { + const finalizeRemovals = (keepBracesOpenFor: Set): void => { for (const { parent, removed } of pendingRemovals.values()) { const children = namedChildren(parent); if (children.every((child) => removed.has(child.range().start.index))) { - edits.push(parent.replace(parent.kind() === "array" ? "[]" : "{}")); - editedRanges.push(rangeOf(parent)); + if (keepBracesOpenFor.has(parent.range().start.index) && children.length) { + // New properties will be inserted after "{" — clear the content only. + const first = children[0].range().start.index; + const lineStart = source.lastIndexOf("\n", first - 1) + 1; + removeText({ + start: lineStart > parent.range().start.index ? lineStart : first, + end: parent.range().end.index - 1, + }); + } else { + edits.push(parent.replace(parent.kind() === "array" ? "[]" : "{}")); + editedRanges.push(rangeOf(parent)); + } continue; } // Delete each contiguous run of removed children up to the next kept @@ -287,7 +313,7 @@ async function transform(root: SgRoot): Promise { // A surviving rule that matches `.css` turns the "auto" default off. if (!ruleMatchesCssFiles(swap.ruleObject)) continue; const config = findConfigForRule(swap.pair); - if (config) configObjects.push(config); + if (config) planFor(config).needsExperimentsCss = true; } } @@ -302,6 +328,28 @@ async function transform(root: SgRoot): Promise { return constructorNode !== null && pluginNames.has(constructorNode.text()); }); if (!removed.length) continue; + // Only `filename`/`chunkFilename` have native counterparts; the rest of the + // plugin options (ignoreOrder, insert, attributes, linkType, runtime) don't. + const optionToOutput = new Map([ + ["filename", "cssFilename"], + ["chunkFilename", "cssChunkFilename"], + ]); + const configObject = pair.parent(); + for (const element of removed) { + if (!configObject || configObject.kind() !== "object") break; + const argumentsNode = element.field("arguments"); + const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; + if (!optionsObject || optionsObject.kind() !== "object") continue; + const plan = planFor(configObject); + for (const optionPair of pairsOf(optionsObject)) { + const mapped = optionToOutput.get(keyName(optionPair) ?? ""); + const optionValue = optionPair.field("value"); + if (!mapped || !optionValue) continue; + if (!plan.outputProps.some((prop) => prop.name === mapped)) { + plan.outputProps.push({ name: mapped, valueText: optionValue.text() }); + } + } + } if (removed.length === elements.length) { markForRemoval(pair); } else { @@ -309,7 +357,59 @@ async function transform(root: SgRoot): Promise { } } - finalizeRemovals(); + // Decide which configs receive new top-level properties before removals are + // finalized — a fully-emptied config keeps its braces open for them. + interface InsertAction { + target: SgNode; + buildProperties: (indent: string, indentUnit: string) => string[]; + } + const insertActions: InsertAction[] = []; + const topInsertTargets = new Set(); + + for (const plan of configPlans.values()) { + const topProperties: ((indent: string, unit: string) => string)[] = []; + const experimentsValue = findPair(plan.config, "experiments")?.field("value"); + if (plan.needsExperimentsCss) { + if (experimentsValue && experimentsValue.kind() === "object") { + if (!findPair(experimentsValue, "css")) { + insertActions.push({ target: experimentsValue, buildProperties: () => ["css: true"] }); + } + } else if (!experimentsValue) { + topProperties.push((indent, unit) => + indent || unit + ? `experiments: {\n${indent}${unit}css: true,\n${indent}}` + : "experiments: { css: true }", + ); + } + } + if (plan.outputProps.length) { + const outputValue = findPair(plan.config, "output")?.field("value"); + const propTexts = plan.outputProps.map((prop) => `${prop.name}: ${prop.valueText}`); + if (outputValue && outputValue.kind() === "object") { + const missing = plan.outputProps + .filter((prop) => !findPair(outputValue, prop.name)) + .map((prop) => `${prop.name}: ${prop.valueText}`); + if (missing.length) { + insertActions.push({ target: outputValue, buildProperties: () => missing }); + } + } else if (!outputValue) { + topProperties.push((indent, unit) => + indent || unit + ? `output: {\n${propTexts.map((text) => `${indent}${unit}${text}`).join(",\n")},\n${indent}}` + : `output: { ${propTexts.join(", ")} }`, + ); + } + } + if (topProperties.length) { + topInsertTargets.add(plan.config.range().start.index); + insertActions.push({ + target: plan.config, + buildProperties: (indent, unit) => topProperties.map((build) => build(indent, unit)), + }); + } + } + + finalizeRemovals(topInsertTargets); if (!edits.length) return null; @@ -335,45 +435,31 @@ async function transform(root: SgRoot): Promise { edits.push({ startPos: statementRange.start, endPos: end, insertedText: "" }); } - // Insert a property right after an object's opening brace, matching its layout. + // Insert properties right after an object's opening brace, matching its layout. const insertIntoObject = ( objectNode: SgNode, - buildProperty: (indent: string, indentUnit: string) => string, + buildProperties: (indent: string, indentUnit: string) => string[], ): void => { - const range = rangeOf(objectNode); - const insertAt = range.start + 1; + const insertAt = objectNode.range().start.index + 1; const properties = namedChildren(objectNode); const multiline = objectNode.text().includes("\n") && properties.length > 0; let insertedText: string; if (multiline) { const indent = lineIndent(source, properties[0].range().start.index); const indentUnit = indent.includes("\t") ? "\t" : indent || " "; - insertedText = `\n${indent}${buildProperty(indent, indentUnit)},`; + insertedText = buildProperties(indent, indentUnit) + .map((property) => `\n${indent}${property},`) + .join(""); } else if (properties.length) { - insertedText = ` ${buildProperty("", "")},`; + insertedText = ` ${buildProperties("", "").join(", ")},`; } else { - insertedText = ` ${buildProperty("", "")} `; + insertedText = ` ${buildProperties("", "").join(", ")} `; } edits.push({ startPos: insertAt, endPos: insertAt, insertedText }); }; - const seenConfigs = new Set(); - for (const config of configObjects) { - const start = config.range().start.index; - if (seenConfigs.has(start)) continue; - seenConfigs.add(start); - const experimentsValue = findPair(config, "experiments")?.field("value"); - if (experimentsValue) { - if (experimentsValue.kind() !== "object") continue; - if (findPair(experimentsValue, "css")) continue; - insertIntoObject(experimentsValue, () => "css: true"); - } else { - insertIntoObject(config, (indent, indentUnit) => - indent || indentUnit - ? `experiments: {\n${indent}${indentUnit}css: true,\n${indent}}` - : "experiments: { css: true }", - ); - } + for (const action of insertActions) { + insertIntoObject(action.target, action.buildProperties); } return rootNode.commitEdits(edits); diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-only.config.js b/codemods/css-plugins-to-native-css/tests/expected/css-only.config.js index f053ebf..4f4a82f 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/css-only.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/css-only.config.js @@ -1 +1,5 @@ -module.exports = {}; +module.exports = { + output: { + cssFilename: "[name].css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js index dc58e99..8ba01eb 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js @@ -4,6 +4,8 @@ const { DefinePlugin } = require("webpack"); module.exports = { entry: "./src/index.js", output: { + cssFilename: "static/[name].css", + cssChunkFilename: "static/[id].css", path: path.resolve(__dirname, "dist"), }, module: { diff --git a/codemods/css-plugins-to-native-css/tests/input/webpack.config.js b/codemods/css-plugins-to-native-css/tests/input/webpack.config.js index c4bc556..5ce6f3b 100644 --- a/codemods/css-plugins-to-native-css/tests/input/webpack.config.js +++ b/codemods/css-plugins-to-native-css/tests/input/webpack.config.js @@ -20,7 +20,7 @@ module.exports = { ], }, plugins: [ - new MiniCssExtractPlugin({ filename: "[name].css" }), + new MiniCssExtractPlugin({ filename: "static/[name].css", chunkFilename: "static/[id].css" }), new DefinePlugin({ DEBUG: "false" }), ], }; From ebb046d01bf93092cb631e67a120597c78651bbd Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:11:04 -0500 Subject: [PATCH 05/59] feat: support webpack 4-era patterns (dev/prod ternary, require.resolve, guarded plugins) --- codemods/css-plugins-to-native-css/README.md | 1 + .../css-plugins-to-native-css/src/workflow.ts | 58 +++++++++++++++---- .../tests/expected/legacy.config.js | 7 +++ .../tests/input/legacy.config.js | 23 ++++++++ 4 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/legacy.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/legacy.config.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 9cdbe6b..dcac3fd 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -9,6 +9,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Any other loader in the chain (`sass-loader`, `less-loader`, `postcss-loader`, custom ones, …) keeps working in front of native CSS: it stays in `use` while the injection/extraction loaders are dropped, and the rule gets `type: "css/auto"`. - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. +- Understands the classic webpack 4-era patterns: the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `require.resolve("css-loader")`, and plugins guarded with `isProd && new MiniCssExtractPlugin(...)` inside `[...].filter(Boolean)`. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 60d86f6..641be83 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -82,18 +82,34 @@ async function transform(root: SgRoot): Promise { ); }; - // Loader name behind a `use` entry: a plain string or `{ loader: "..." }`. + // Loader name behind a `use` entry: a plain string, `require.resolve("...")`, + // or `{ loader: }`. const loaderNameOf = (node: SgNode): string | null => { if (node.kind() === "string") return unquote(node.text()); + if (node.kind() === "call_expression") { + const resolved = /^require\.resolve\(\s*(["'`][^"'`]+["'`])\s*\)$/.exec(node.text()); + return resolved ? unquote(resolved[1]) : null; + } if (node.kind() !== "object") return null; const loaderValue = findPair(node, "loader")?.field("value"); - return loaderValue && loaderValue.kind() === "string" ? unquote(loaderValue.text()) : null; + return loaderValue ? loaderNameOf(loaderValue) : null; }; // A `use` entry replaceable by native CSS: a known loader string, the - // plugin's `.loader`, or `{ loader: , ... }`. + // plugin's `.loader`, `{ loader: , ... }`, or the classic + // dev/prod ternary where both branches are replaceable. const isRemovableUseElement = (node: SgNode): boolean => { if (isPluginLoaderExpression(node)) return true; + if (node.kind() === "ternary_expression") { + const consequence = node.field("consequence"); + const alternative = node.field("alternative"); + return ( + consequence !== null && + alternative !== null && + isRemovableUseElement(consequence) && + isRemovableUseElement(alternative) + ); + } if (node.kind() === "object") { const loaderValue = findPair(node, "loader")?.field("value"); if (loaderValue && isPluginLoaderExpression(loaderValue)) return true; @@ -317,16 +333,38 @@ async function transform(root: SgRoot): Promise { } } + // The `new MiniCssExtractPlugin(...)` behind a plugins element, unwrapping the + // `isProd && new Plugin()` / `isDev ? false : new Plugin()` guard patterns. + const pluginInstantiationOf = (element: SgNode): SgNode | null => { + const candidates: (SgNode | null)[] = [element]; + if (element.kind() === "binary_expression" && element.field("operator")?.text() === "&&") { + candidates.push(element.field("right")); + } + if (element.kind() === "ternary_expression") { + candidates.push(element.field("consequence"), element.field("alternative")); + } + for (const candidate of candidates) { + if (!candidate || candidate.kind() !== "new_expression") continue; + const constructorNode = candidate.field("constructor"); + if (constructorNode && pluginNames.has(constructorNode.text())) return candidate; + } + return null; + }; + for (const pair of rootNode.findAll({ rule: { kind: "pair" } })) { if (keyName(pair) !== "plugins") continue; - const value = pair.field("value"); + let value = pair.field("value"); + // `plugins: [ ... ].filter(Boolean)` — operate on the inner array literal. + if (value && value.kind() === "call_expression") { + const callee = value.field("function"); + if (callee && callee.kind() === "member_expression") { + const receiver = callee.field("object"); + if (receiver && receiver.kind() === "array") value = receiver; + } + } if (!value || value.kind() !== "array") continue; const elements = namedChildren(value); - const removed = elements.filter((element) => { - if (element.kind() !== "new_expression") return false; - const constructorNode = element.field("constructor"); - return constructorNode !== null && pluginNames.has(constructorNode.text()); - }); + const removed = elements.filter((element) => pluginInstantiationOf(element) !== null); if (!removed.length) continue; // Only `filename`/`chunkFilename` have native counterparts; the rest of the // plugin options (ignoreOrder, insert, attributes, linkType, runtime) don't. @@ -337,7 +375,7 @@ async function transform(root: SgRoot): Promise { const configObject = pair.parent(); for (const element of removed) { if (!configObject || configObject.kind() !== "object") break; - const argumentsNode = element.field("arguments"); + const argumentsNode = pluginInstantiationOf(element)?.field("arguments"); const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; if (!optionsObject || optionsObject.kind() !== "object") continue; const plan = planFor(configObject); diff --git a/codemods/css-plugins-to-native-css/tests/expected/legacy.config.js b/codemods/css-plugins-to-native-css/tests/expected/legacy.config.js new file mode 100644 index 0000000..afd19cf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/legacy.config.js @@ -0,0 +1,7 @@ +const devMode = process.env.NODE_ENV !== "production"; + +module.exports = { + output: { + cssFilename: "[name].[contenthash].css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/legacy.config.js b/codemods/css-plugins-to-native-css/tests/input/legacy.config.js new file mode 100644 index 0000000..a7df0d9 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/legacy.config.js @@ -0,0 +1,23 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +const devMode = process.env.NODE_ENV !== "production"; + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [ + devMode ? "style-loader" : MiniCssExtractPlugin.loader, + { + loader: require.resolve("css-loader"), + options: { importLoaders: 1 }, + }, + ], + }, + ], + }, + plugins: [ + !devMode && new MiniCssExtractPlugin({ filename: "[name].[contenthash].css" }), + ].filter(Boolean), +}; From 498694ea7713336ff36e1dc6e9aeaee218a6fc17 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:16:02 -0500 Subject: [PATCH 06/59] feat: support ejected CRA patterns (guarded use entries, filter(Boolean) on use) --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 35 +++++++++++++------ .../tests/expected/cra-ejected.config.js | 9 +++++ .../tests/input/cra-ejected.config.js | 32 +++++++++++++++++ 4 files changed, 67 insertions(+), 11 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/cra-ejected.config.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index dcac3fd..a2a571e 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -9,7 +9,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Any other loader in the chain (`sass-loader`, `less-loader`, `postcss-loader`, custom ones, …) keeps working in front of native CSS: it stays in `use` while the injection/extraction loaders are dropped, and the rule gets `type: "css/auto"`. - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. -- Understands the classic webpack 4-era patterns: the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `require.resolve("css-loader")`, and plugins guarded with `isProd && new MiniCssExtractPlugin(...)` inside `[...].filter(Boolean)`. +- Understands the classic webpack 4-era patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 641be83..9e6ba19 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -100,6 +100,11 @@ async function transform(root: SgRoot): Promise { // dev/prod ternary where both branches are replaceable. const isRemovableUseElement = (node: SgNode): boolean => { if (isPluginLoaderExpression(node)) return true; + // CRA-style guard: `isEnvDevelopment && "style-loader"` inside `[...].filter(Boolean)`. + if (node.kind() === "binary_expression" && node.field("operator")?.text() === "&&") { + const right = node.field("right"); + return right !== null && isRemovableUseElement(right); + } if (node.kind() === "ternary_expression") { const consequence = node.field("consequence"); const alternative = node.field("alternative"); @@ -118,6 +123,15 @@ async function transform(root: SgRoot): Promise { return name !== null && REMOVABLE_LOADERS.has(name); }; + // `[ ... ].filter(Boolean)` — return the inner array literal. + const unwrapFilterCall = (node: SgNode): SgNode => { + if (node.kind() !== "call_expression") return node; + const callee = node.field("function"); + if (!callee || callee.kind() !== "member_expression") return node; + const receiver = callee.field("object"); + return receiver && receiver.kind() === "array" ? receiver : node; + }; + // Whether the rule still claims plain `.css` resources after the transform — // if so it disables the `experiments.css: "auto"` default, which then needs // an explicit `true`. Unreadable conditions count as matching, to be safe. @@ -257,8 +271,9 @@ async function transform(root: SgRoot): Promise { for (const pair of rootNode.findAll({ rule: { kind: "pair" } })) { if (keyName(pair) !== "use") continue; - const value = pair.field("value"); + let value = pair.field("value"); if (!value) continue; + value = unwrapFilterCall(value); const elements = value.kind() === "array" ? namedChildren(value) : [value]; if (!elements.length) continue; const removable = elements.filter(isRemovableUseElement); @@ -317,10 +332,17 @@ async function transform(root: SgRoot): Promise { if (swap.keptLoaders.length) { // Preprocessor loaders stay in `use`; native CSS parses their output. const keptTexts = swap.keptLoaders.map((loader) => loader.text()); + const keepsGuard = swap.keptLoaders.some( + (loader) => + loader.kind() === "binary_expression" || loader.kind() === "ternary_expression", + ); + const filterSuffix = keepsGuard ? ".filter(Boolean)" : ""; const indent = lineIndent(source, swap.pair.range().start.index); const separator = swap.ruleObject.text().includes("\n") ? `,\n${indent}` : ", "; edits.push( - swap.pair.replace(`use: [${keptTexts.join(", ")}]${separator}type: "css/auto"`), + swap.pair.replace( + `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}type: "css/auto"`, + ), ); } else { edits.push(swap.pair.replace('type: "css/auto"')); @@ -354,14 +376,7 @@ async function transform(root: SgRoot): Promise { for (const pair of rootNode.findAll({ rule: { kind: "pair" } })) { if (keyName(pair) !== "plugins") continue; let value = pair.field("value"); - // `plugins: [ ... ].filter(Boolean)` — operate on the inner array literal. - if (value && value.kind() === "call_expression") { - const callee = value.field("function"); - if (callee && callee.kind() === "member_expression") { - const receiver = callee.field("object"); - if (receiver && receiver.kind() === "array") value = receiver; - } - } + if (value) value = unwrapFilterCall(value); if (!value || value.kind() !== "array") continue; const elements = namedChildren(value); const removed = elements.filter((element) => pluginInstantiationOf(element) !== null); diff --git a/codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js b/codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js new file mode 100644 index 0000000..6c19cac --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js @@ -0,0 +1,9 @@ +const isEnvDevelopment = process.env.NODE_ENV === "development"; +const isEnvProduction = process.env.NODE_ENV === "production"; + +module.exports = { + output: { + cssFilename: "static/css/[name].[contenthash:8].css", + cssChunkFilename: "static/css/[name].[contenthash:8].chunk.css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/cra-ejected.config.js b/codemods/css-plugins-to-native-css/tests/input/cra-ejected.config.js new file mode 100644 index 0000000..ab9b4dd --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/cra-ejected.config.js @@ -0,0 +1,32 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +const isEnvDevelopment = process.env.NODE_ENV === "development"; +const isEnvProduction = process.env.NODE_ENV === "production"; + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [ + isEnvDevelopment && require.resolve("style-loader"), + isEnvProduction && { + loader: MiniCssExtractPlugin.loader, + options: { publicPath: "../../" }, + }, + { + loader: require.resolve("css-loader"), + options: { importLoaders: 1 }, + }, + ].filter(Boolean), + }, + ], + }, + plugins: [ + isEnvProduction && + new MiniCssExtractPlugin({ + filename: "static/css/[name].[contenthash:8].css", + chunkFilename: "static/css/[name].[contenthash:8].chunk.css", + }), + ].filter(Boolean), +}; From 14e8748317480d5aeb390c741e4bc04361c6ff94 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:16:43 -0500 Subject: [PATCH 07/59] docs: drop webpack 4-era wording from readme --- codemods/css-plugins-to-native-css/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index a2a571e..ea0fde2 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -9,7 +9,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Any other loader in the chain (`sass-loader`, `less-loader`, `postcss-loader`, custom ones, …) keeps working in front of native CSS: it stays in `use` while the injection/extraction loaders are dropped, and the rule gets `type: "css/auto"`. - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. -- Understands the classic webpack 4-era patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. +- Understands the classic conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. From cdf0a8c820824c12f919fdf23f576eb7b0a0b9b8 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:18:01 -0500 Subject: [PATCH 08/59] docs: simplify conditional patterns wording --- codemods/css-plugins-to-native-css/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index ea0fde2..d439076 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -9,7 +9,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Any other loader in the chain (`sass-loader`, `less-loader`, `postcss-loader`, custom ones, …) keeps working in front of native CSS: it stays in `use` while the injection/extraction loaders are dropped, and the rule gets `type: "css/auto"`. - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. -- Understands the classic conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. +- Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. From c3dd78f0798f7a740d027c5570d3b95fc879589c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:19:36 -0500 Subject: [PATCH 09/59] docs: note webpack >= 5.109.0 requirement --- codemods/css-plugins-to-native-css/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index d439076..9d673fa 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -2,6 +2,8 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader`/`css-loader` rules to webpack's [native CSS support](https://webpack.js.org/configuration/experiments/#experimentscss) (`experiments.css`). +> Requires **webpack >= 5.109.0**: the transform relies on the `experiments.css: "auto"` default introduced there, which enables native CSS whenever no user rule matches `.css` files. + ## What it does - Removes rules that only wire up `style-loader`, `css-loader`, and/or `MiniCssExtractPlugin.loader` (cascading to empty `rules`/`module` entries): with no user rule matching `.css`, webpack's `experiments.css: "auto"` default enables native CSS by itself, so no explicit option is needed. From f327307826b2d67f4adc8fda7eeb8f2f82758953 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:24:14 -0500 Subject: [PATCH 10/59] refactor: restructure transform into a CssMigration class with named phases --- .../css-plugins-to-native-css/src/workflow.ts | 830 ++++++++++-------- 1 file changed, 455 insertions(+), 375 deletions(-) diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 9e6ba19..8f4c288 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -3,12 +3,49 @@ import type { Edit, SgNode, SgRoot } from "@codemod.com/jssg-types/main"; const PLUGIN_MODULE = "mini-css-extract-plugin"; const REMOVABLE_LOADERS = new Set(["style-loader", "css-loader"]); +// Only `filename`/`chunkFilename` have native counterparts; the rest of the +// plugin options (ignoreOrder, insert, attributes, linkType, runtime) don't. +const PLUGIN_OPTION_TO_OUTPUT = new Map([ + ["filename", "cssFilename"], + ["chunkFilename", "cssChunkFilename"], +]); interface Range { start: number; end: number; } +interface PluginBinding { + name: string; + statement: SgNode; +} + +// Properties to add to one webpack config object once all removals are known. +interface ConfigPlan { + config: SgNode; + needsExperimentsCss: boolean; + outputProps: { name: string; valueText: string }[]; +} + +interface UseSwap { + pair: SgNode; + ruleObject: SgNode; + keptLoaders: SgNode[]; +} + +interface RulesArrayWork { + arrayNode: SgNode; + removedElements: SgNode[]; + swaps: UseSwap[]; +} + +interface InsertAction { + target: SgNode; + buildProperties: (indent: string, indentUnit: string) => string[]; +} + +// ---------- generic AST helpers ---------- + function rangeOf(node: SgNode): Range { const range = node.range(); return { start: range.start.index, end: range.end.index }; @@ -46,64 +83,137 @@ function isInsideAny(range: Range, ranges: Range[]): boolean { return ranges.some((outer) => range.start >= outer.start && range.end <= outer.end); } -async function transform(root: SgRoot): Promise { - const rootNode = root.root(); - const source = rootNode.text(); - - // Bindings introduced by `require`/`import` of mini-css-extract-plugin. - const pluginBindings: { name: string; statement: SgNode }[] = []; - const importPatterns = [ - "const $NAME = require($SOURCE)", - "let $NAME = require($SOURCE)", - "var $NAME = require($SOURCE)", - "import $NAME from $SOURCE", - ]; - for (const pattern of importPatterns) { - for (const statement of rootNode.findAll({ rule: { pattern } })) { - const name = statement.getMatch("NAME"); - const moduleSource = statement.getMatch("SOURCE"); - if (!name || !moduleSource) continue; - if (unquote(moduleSource.text()) !== PLUGIN_MODULE) continue; - pluginBindings.push({ name: name.text(), statement }); +// `[ ... ].filter(Boolean)` — return the inner array literal. +function unwrapFilterCall(node: SgNode): SgNode { + if (node.kind() !== "call_expression") return node; + const callee = node.field("function"); + if (!callee || callee.kind() !== "member_expression") return node; + const receiver = callee.field("object"); + return receiver && receiver.kind() === "array" ? receiver : node; +} + +// ---------- webpack-specific recognition ---------- + +// Loader name behind a `use` entry: a plain string, `require.resolve("...")`, +// or `{ loader: }`. +function loaderNameOf(node: SgNode): string | null { + if (node.kind() === "string") return unquote(node.text()); + if (node.kind() === "call_expression") { + const resolved = /^require\.resolve\(\s*(["'`][^"'`]+["'`])\s*\)$/.exec(node.text()); + return resolved ? unquote(resolved[1]) : null; + } + if (node.kind() !== "object") return null; + const loaderValue = findPair(node, "loader")?.field("value"); + return loaderValue ? loaderNameOf(loaderValue) : null; +} + +// Whether the rule still claims plain `.css` resources after the transform — +// if so it disables the `experiments.css: "auto"` default, which then needs +// an explicit `true`. Unreadable conditions count as matching, to be safe. +function ruleMatchesCssFiles(ruleObject: SgNode): boolean { + const testValue = findPair(ruleObject, "test")?.field("value"); + if (!testValue) return true; + if (testValue.kind() !== "regex") return true; + const literal = /^\/(.*)\/([a-z]*)$/s.exec(testValue.text()); + if (!literal) return true; + try { + const regex = new RegExp(literal[1], literal[2]); + return regex.test("/file.css") || regex.test("/file.module.css"); + } catch { + return true; + } +} + +// The enclosing webpack config object: nearest ancestor holding a `module` pair. +function findConfigForRule(node: SgNode): SgNode | null { + let current = node.parent(); + while (current) { + if (current.kind() === "pair" && keyName(current) === "module") { + const parent = current.parent(); + if (parent && parent.kind() === "object") return parent; + } + current = current.parent(); + } + return null; +} + +class CssMigration { + private readonly rootNode: SgNode; + private readonly source: string; + private readonly pluginBindings: PluginBinding[] = []; + private readonly pluginNames: Set; + + private readonly edits: Edit[] = []; + private readonly editedRanges: Range[] = []; + private readonly configPlans = new Map(); + // Removals of comma-separated list items are grouped per parent container so + // sibling removals in the same object/array never produce overlapping ranges. + private readonly pendingRemovals = new Map; removed: Set }>(); + private readonly insertActions: InsertAction[] = []; + // Fully-emptied objects that must keep their braces open for new properties. + private readonly topInsertTargets = new Set(); + + constructor(root: SgRoot) { + this.rootNode = root.root(); + this.source = this.rootNode.text(); + this.collectPluginBindings(); + this.pluginNames = new Set(this.pluginBindings.map((binding) => binding.name)); + } + + run(): string | null { + this.transformRules(); + this.transformPlugins(); + this.planConfigInsertions(); + this.finalizeRemovals(); + if (!this.edits.length) return null; + this.removeUnusedImports(); + for (const action of this.insertActions) { + this.insertIntoObject(action.target, action.buildProperties); + } + return this.rootNode.commitEdits(this.edits); + } + + // ---------- plugin import detection ---------- + + private collectPluginBindings(): void { + const importPatterns = [ + "const $NAME = require($SOURCE)", + "let $NAME = require($SOURCE)", + "var $NAME = require($SOURCE)", + "import $NAME from $SOURCE", + ]; + for (const pattern of importPatterns) { + for (const statement of this.rootNode.findAll({ rule: { pattern } })) { + const name = statement.getMatch("NAME"); + const moduleSource = statement.getMatch("SOURCE"); + if (!name || !moduleSource) continue; + if (unquote(moduleSource.text()) !== PLUGIN_MODULE) continue; + this.pluginBindings.push({ name: name.text(), statement }); + } } } - const pluginNames = new Set(pluginBindings.map((binding) => binding.name)); // `MiniCssExtractPlugin.loader` or `require("mini-css-extract-plugin").loader`. - const isPluginLoaderExpression = (node: SgNode): boolean => { + private isPluginLoaderExpression(node: SgNode): boolean { if (node.kind() !== "member_expression") return false; const objectPart = node.field("object"); const propertyPart = node.field("property"); if (!objectPart || !propertyPart || propertyPart.text() !== "loader") return false; - if (objectPart.kind() === "identifier") return pluginNames.has(objectPart.text()); + if (objectPart.kind() === "identifier") return this.pluginNames.has(objectPart.text()); return ( objectPart.kind() === "call_expression" && /^require\(\s*["'`]mini-css-extract-plugin["'`]\s*\)$/.test(objectPart.text()) ); - }; - - // Loader name behind a `use` entry: a plain string, `require.resolve("...")`, - // or `{ loader: }`. - const loaderNameOf = (node: SgNode): string | null => { - if (node.kind() === "string") return unquote(node.text()); - if (node.kind() === "call_expression") { - const resolved = /^require\.resolve\(\s*(["'`][^"'`]+["'`])\s*\)$/.exec(node.text()); - return resolved ? unquote(resolved[1]) : null; - } - if (node.kind() !== "object") return null; - const loaderValue = findPair(node, "loader")?.field("value"); - return loaderValue ? loaderNameOf(loaderValue) : null; - }; + } // A `use` entry replaceable by native CSS: a known loader string, the - // plugin's `.loader`, `{ loader: , ... }`, or the classic - // dev/prod ternary where both branches are replaceable. - const isRemovableUseElement = (node: SgNode): boolean => { - if (isPluginLoaderExpression(node)) return true; - // CRA-style guard: `isEnvDevelopment && "style-loader"` inside `[...].filter(Boolean)`. + // plugin's `.loader`, `{ loader: , ... }`, or the dev/prod + // `cond ? a : b` / `cond && a` forms where every branch is replaceable. + private isRemovableUseElement(node: SgNode): boolean { + if (this.isPluginLoaderExpression(node)) return true; if (node.kind() === "binary_expression" && node.field("operator")?.text() === "&&") { const right = node.field("right"); - return right !== null && isRemovableUseElement(right); + return right !== null && this.isRemovableUseElement(right); } if (node.kind() === "ternary_expression") { const consequence = node.field("consequence"); @@ -111,291 +221,171 @@ async function transform(root: SgRoot): Promise { return ( consequence !== null && alternative !== null && - isRemovableUseElement(consequence) && - isRemovableUseElement(alternative) + this.isRemovableUseElement(consequence) && + this.isRemovableUseElement(alternative) ); } if (node.kind() === "object") { const loaderValue = findPair(node, "loader")?.field("value"); - if (loaderValue && isPluginLoaderExpression(loaderValue)) return true; + if (loaderValue && this.isPluginLoaderExpression(loaderValue)) return true; } const name = loaderNameOf(node); return name !== null && REMOVABLE_LOADERS.has(name); - }; - - // `[ ... ].filter(Boolean)` — return the inner array literal. - const unwrapFilterCall = (node: SgNode): SgNode => { - if (node.kind() !== "call_expression") return node; - const callee = node.field("function"); - if (!callee || callee.kind() !== "member_expression") return node; - const receiver = callee.field("object"); - return receiver && receiver.kind() === "array" ? receiver : node; - }; - - // Whether the rule still claims plain `.css` resources after the transform — - // if so it disables the `experiments.css: "auto"` default, which then needs - // an explicit `true`. Unreadable conditions count as matching, to be safe. - const ruleMatchesCssFiles = (ruleObject: SgNode): boolean => { - const testValue = findPair(ruleObject, "test")?.field("value"); - if (!testValue) return true; - if (testValue.kind() !== "regex") return true; - const literal = /^\/(.*)\/([a-z]*)$/s.exec(testValue.text()); - if (!literal) return true; - try { - const regex = new RegExp(literal[1], literal[2]); - return regex.test("/file.css") || regex.test("/file.module.css"); - } catch { - return true; - } - }; - - const edits: Edit[] = []; - const editedRanges: Range[] = []; - - // Per-config plan of properties to add once removals are known. - interface ConfigPlan { - config: SgNode; - needsExperimentsCss: boolean; - outputProps: { name: string; valueText: string }[]; } - const configPlans = new Map(); - const planFor = (config: SgNode): ConfigPlan => { - const key = config.range().start.index; - let plan = configPlans.get(key); - if (!plan) { - plan = { config, needsExperimentsCss: false, outputProps: [] }; - configPlans.set(key, plan); - } - return plan; - }; - - const removeText = (range: Range): void => { - edits.push({ startPos: range.start, endPos: range.end, insertedText: "" }); - editedRanges.push(range); - }; - - // Removals of comma-separated list items are grouped per parent container so - // sibling removals in the same object/array never produce overlapping ranges. - const pendingRemovals = new Map; removed: Set }>(); - const markForRemoval = (node: SgNode): void => { - const parent = node.parent(); - if (!parent) return; - const key = parent.range().start.index; - let group = pendingRemovals.get(key); - if (!group) { - group = { parent, removed: new Set() }; - pendingRemovals.set(key, group); + // The `new MiniCssExtractPlugin(...)` behind a plugins element, unwrapping + // the `isProd && new Plugin()` / `isDev ? false : new Plugin()` guards. + private pluginInstantiationOf(element: SgNode): SgNode | null { + const candidates: (SgNode | null)[] = [element]; + if (element.kind() === "binary_expression" && element.field("operator")?.text() === "&&") { + candidates.push(element.field("right")); } - group.removed.add(node.range().start.index); - }; + if (element.kind() === "ternary_expression") { + candidates.push(element.field("consequence"), element.field("alternative")); + } + for (const candidate of candidates) { + if (!candidate || candidate.kind() !== "new_expression") continue; + const constructorNode = candidate.field("constructor"); + if (constructorNode && this.pluginNames.has(constructorNode.text())) return candidate; + } + return null; + } - const finalizeRemovals = (keepBracesOpenFor: Set): void => { - for (const { parent, removed } of pendingRemovals.values()) { - const children = namedChildren(parent); - if (children.every((child) => removed.has(child.range().start.index))) { - if (keepBracesOpenFor.has(parent.range().start.index) && children.length) { - // New properties will be inserted after "{" — clear the content only. - const first = children[0].range().start.index; - const lineStart = source.lastIndexOf("\n", first - 1) + 1; - removeText({ - start: lineStart > parent.range().start.index ? lineStart : first, - end: parent.range().end.index - 1, - }); - } else { - edits.push(parent.replace(parent.kind() === "array" ? "[]" : "{}")); - editedRanges.push(rangeOf(parent)); - } + // ---------- module.rules ---------- + + // Rules holding only `test` + `use` are dropped outright: with no user rule + // matching `.css`, `experiments.css: "auto"` enables native CSS by itself. + // Rules that must stay get `type: "css/auto"` (plus `experiments.css: true` + // when they match `.css`, since their presence disables the "auto" default). + private transformRules(): void { + const rulesWork = this.collectRulesWork(); + for (const work of rulesWork.values()) { + const allElements = namedChildren(work.arrayNode); + if (work.removedElements.length === allElements.length && !work.swaps.length) { + this.markForRemoval(this.escalateEmptyRulesArray(work.arrayNode)); continue; } - // Delete each contiguous run of removed children up to the next kept - // sibling, or back to the previous kept one for a trailing run. - let index = 0; - while (index < children.length) { - if (!removed.has(children[index].range().start.index)) { - index += 1; - continue; - } - let runEnd = index; - while ( - runEnd + 1 < children.length && - removed.has(children[runEnd + 1].range().start.index) - ) { - runEnd += 1; - } - const next = children[runEnd + 1]; - if (next) { - removeText({ - start: children[index].range().start.index, - end: next.range().start.index, - }); - } else { - removeText({ - start: children[index - 1].range().end.index, - end: children[runEnd].range().end.index, - }); - } - index = runEnd + 1; + for (const element of work.removedElements) { + this.markForRemoval(element); } - } - }; - - // The enclosing webpack config object: nearest ancestor holding a `module` pair. - const findConfigForRule = (node: SgNode): SgNode | null => { - let current = node.parent(); - while (current) { - if (current.kind() === "pair" && keyName(current) === "module") { - const parent = current.parent(); - if (parent && parent.kind() === "object") return parent; + for (const swap of work.swaps) { + this.replaceUsePair(swap); } - current = current.parent(); } - return null; - }; - - // Rules holding only `test` + `use` can be dropped outright: with no user rule - // matching `.css`, `experiments.css: "auto"` enables native CSS by itself. - // Rules with extra conditions must stay, which disables the "auto" default — - // only those configs need an explicit `experiments.css: true`. - interface UseSwap { - pair: SgNode; - ruleObject: SgNode; - keptLoaders: SgNode[]; } - interface RulesArrayWork { - arrayNode: SgNode; - removedElements: SgNode[]; - swaps: UseSwap[]; + + private collectRulesWork(): Map { + const rulesWork = new Map(); + for (const pair of this.rootNode.findAll({ rule: { kind: "pair" } })) { + if (keyName(pair) !== "use") continue; + let value = pair.field("value"); + if (!value) continue; + value = unwrapFilterCall(value); + const elements = value.kind() === "array" ? namedChildren(value) : [value]; + if (!elements.length) continue; + const removable = elements.filter((element) => this.isRemovableUseElement(element)); + // Any other loader (preprocessors, custom ones) stays in front of native CSS. + const kept = elements.filter((element) => !this.isRemovableUseElement(element)); + if (!removable.length) continue; + const ruleObject = pair.parent(); + if (!ruleObject || ruleObject.kind() !== "object") continue; + const arrayNode = ruleObject.parent(); + if (!arrayNode) continue; + const key = arrayNode.range().start.index; + let work = rulesWork.get(key); + if (!work) { + work = { arrayNode, removedElements: [], swaps: [] }; + rulesWork.set(key, work); + } + const trivialRule = pairsOf(ruleObject).every((rulePair) => { + const name = keyName(rulePair); + return name === "test" || name === "use"; + }); + if (trivialRule && !kept.length && arrayNode.kind() === "array") { + work.removedElements.push(ruleObject); + } else { + work.swaps.push({ pair, ruleObject, keptLoaders: kept }); + } + } + return rulesWork; } - const rulesWork = new Map(); - - for (const pair of rootNode.findAll({ rule: { kind: "pair" } })) { - if (keyName(pair) !== "use") continue; - let value = pair.field("value"); - if (!value) continue; - value = unwrapFilterCall(value); - const elements = value.kind() === "array" ? namedChildren(value) : [value]; - if (!elements.length) continue; - const removable = elements.filter(isRemovableUseElement); - // Any other loader (preprocessors, custom ones) stays in front of native CSS. - const kept = elements.filter((element) => !isRemovableUseElement(element)); - if (!removable.length) continue; - const ruleObject = pair.parent(); - if (!ruleObject || ruleObject.kind() !== "object") continue; - const arrayNode = ruleObject.parent(); - const key = arrayNode ? arrayNode.range().start.index : rangeOf(pair).start; - let work = rulesWork.get(key); - if (!work && arrayNode) { - work = { arrayNode, removedElements: [], swaps: [] }; - rulesWork.set(key, work); + + // The whole rules array goes away — cascade to `rules`/`module` when empty. + private escalateEmptyRulesArray(arrayNode: SgNode): SgNode { + const rulesPair = arrayNode.parent(); + if (!rulesPair || rulesPair.kind() !== "pair") return arrayNode; + const moduleObject = rulesPair.parent(); + const modulePair = moduleObject ? moduleObject.parent() : null; + if ( + moduleObject && + moduleObject.kind() === "object" && + pairsOf(moduleObject).length === 1 && + modulePair && + modulePair.kind() === "pair" && + keyName(modulePair) === "module" + ) { + return modulePair; } - if (!work) continue; - const trivialRule = pairsOf(ruleObject).every((rulePair) => { - const name = keyName(rulePair); - return name === "test" || name === "use"; - }); - if (trivialRule && !kept.length && arrayNode && arrayNode.kind() === "array") { - work.removedElements.push(ruleObject); + return rulesPair; + } + + private replaceUsePair(swap: UseSwap): void { + if (swap.keptLoaders.length) { + // Kept loaders stay in `use`; native CSS parses their output. Guarded + // entries keep the `.filter(Boolean)` that drops their falsy branch. + const keptTexts = swap.keptLoaders.map((loader) => loader.text()); + const keepsGuard = swap.keptLoaders.some( + (loader) => + loader.kind() === "binary_expression" || loader.kind() === "ternary_expression", + ); + const filterSuffix = keepsGuard ? ".filter(Boolean)" : ""; + const indent = lineIndent(this.source, swap.pair.range().start.index); + const separator = swap.ruleObject.text().includes("\n") ? `,\n${indent}` : ", "; + this.edits.push( + swap.pair.replace( + `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}type: "css/auto"`, + ), + ); } else { - work.swaps.push({ pair, ruleObject, keptLoaders: kept }); + this.edits.push(swap.pair.replace('type: "css/auto"')); } + this.editedRanges.push(rangeOf(swap.pair)); + // A surviving rule that matches `.css` turns the "auto" default off. + if (!ruleMatchesCssFiles(swap.ruleObject)) return; + const config = findConfigForRule(swap.pair); + if (config) this.planFor(config).needsExperimentsCss = true; } - for (const work of rulesWork.values()) { - const allElements = namedChildren(work.arrayNode); - if (work.removedElements.length === allElements.length && !work.swaps.length) { - // The whole rules array goes away — cascade to `rules`/`module` when empty. - let removalTarget: SgNode = work.arrayNode; - const rulesPair = work.arrayNode.parent(); - if (rulesPair && rulesPair.kind() === "pair") { - removalTarget = rulesPair; - const moduleObject = rulesPair.parent(); - const modulePair = moduleObject ? moduleObject.parent() : null; - if ( - moduleObject && - moduleObject.kind() === "object" && - pairsOf(moduleObject).length === 1 && - modulePair && - modulePair.kind() === "pair" && - keyName(modulePair) === "module" - ) { - removalTarget = modulePair; - } - } - markForRemoval(removalTarget); - continue; - } - for (const element of work.removedElements) { - markForRemoval(element); - } - for (const swap of work.swaps) { - if (swap.keptLoaders.length) { - // Preprocessor loaders stay in `use`; native CSS parses their output. - const keptTexts = swap.keptLoaders.map((loader) => loader.text()); - const keepsGuard = swap.keptLoaders.some( - (loader) => - loader.kind() === "binary_expression" || loader.kind() === "ternary_expression", - ); - const filterSuffix = keepsGuard ? ".filter(Boolean)" : ""; - const indent = lineIndent(source, swap.pair.range().start.index); - const separator = swap.ruleObject.text().includes("\n") ? `,\n${indent}` : ", "; - edits.push( - swap.pair.replace( - `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}type: "css/auto"`, - ), - ); + // ---------- plugins ---------- + + private transformPlugins(): void { + for (const pair of this.rootNode.findAll({ rule: { kind: "pair" } })) { + if (keyName(pair) !== "plugins") continue; + let value = pair.field("value"); + if (value) value = unwrapFilterCall(value); + if (!value || value.kind() !== "array") continue; + const elements = namedChildren(value); + const removed = elements.filter((element) => this.pluginInstantiationOf(element) !== null); + if (!removed.length) continue; + this.collectPluginOptions(pair, removed); + if (removed.length === elements.length) { + this.markForRemoval(pair); } else { - edits.push(swap.pair.replace('type: "css/auto"')); + for (const element of removed) this.markForRemoval(element); } - editedRanges.push(rangeOf(swap.pair)); - // A surviving rule that matches `.css` turns the "auto" default off. - if (!ruleMatchesCssFiles(swap.ruleObject)) continue; - const config = findConfigForRule(swap.pair); - if (config) planFor(config).needsExperimentsCss = true; } } - // The `new MiniCssExtractPlugin(...)` behind a plugins element, unwrapping the - // `isProd && new Plugin()` / `isDev ? false : new Plugin()` guard patterns. - const pluginInstantiationOf = (element: SgNode): SgNode | null => { - const candidates: (SgNode | null)[] = [element]; - if (element.kind() === "binary_expression" && element.field("operator")?.text() === "&&") { - candidates.push(element.field("right")); - } - if (element.kind() === "ternary_expression") { - candidates.push(element.field("consequence"), element.field("alternative")); - } - for (const candidate of candidates) { - if (!candidate || candidate.kind() !== "new_expression") continue; - const constructorNode = candidate.field("constructor"); - if (constructorNode && pluginNames.has(constructorNode.text())) return candidate; - } - return null; - }; - - for (const pair of rootNode.findAll({ rule: { kind: "pair" } })) { - if (keyName(pair) !== "plugins") continue; - let value = pair.field("value"); - if (value) value = unwrapFilterCall(value); - if (!value || value.kind() !== "array") continue; - const elements = namedChildren(value); - const removed = elements.filter((element) => pluginInstantiationOf(element) !== null); - if (!removed.length) continue; - // Only `filename`/`chunkFilename` have native counterparts; the rest of the - // plugin options (ignoreOrder, insert, attributes, linkType, runtime) don't. - const optionToOutput = new Map([ - ["filename", "cssFilename"], - ["chunkFilename", "cssChunkFilename"], - ]); - const configObject = pair.parent(); + private collectPluginOptions(pluginsPair: SgNode, removed: SgNode[]): void { + const configObject = pluginsPair.parent(); + if (!configObject || configObject.kind() !== "object") return; for (const element of removed) { - if (!configObject || configObject.kind() !== "object") break; - const argumentsNode = pluginInstantiationOf(element)?.field("arguments"); + const argumentsNode = this.pluginInstantiationOf(element)?.field("arguments"); const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; if (!optionsObject || optionsObject.kind() !== "object") continue; - const plan = planFor(configObject); + const plan = this.planFor(configObject); for (const optionPair of pairsOf(optionsObject)) { - const mapped = optionToOutput.get(keyName(optionPair) ?? ""); + const mapped = PLUGIN_OPTION_TO_OUTPUT.get(keyName(optionPair) ?? ""); const optionValue = optionPair.field("value"); if (!mapped || !optionValue) continue; if (!plan.outputProps.some((prop) => prop.name === mapped)) { @@ -403,102 +393,88 @@ async function transform(root: SgRoot): Promise { } } } - if (removed.length === elements.length) { - markForRemoval(pair); - } else { - for (const element of removed) markForRemoval(element); - } } - // Decide which configs receive new top-level properties before removals are - // finalized — a fully-emptied config keeps its braces open for them. - interface InsertAction { - target: SgNode; - buildProperties: (indent: string, indentUnit: string) => string[]; + // ---------- config-level insertions ---------- + + private planFor(config: SgNode): ConfigPlan { + const key = config.range().start.index; + let plan = this.configPlans.get(key); + if (!plan) { + plan = { config, needsExperimentsCss: false, outputProps: [] }; + this.configPlans.set(key, plan); + } + return plan; } - const insertActions: InsertAction[] = []; - const topInsertTargets = new Set(); - for (const plan of configPlans.values()) { - const topProperties: ((indent: string, unit: string) => string)[] = []; - const experimentsValue = findPair(plan.config, "experiments")?.field("value"); - if (plan.needsExperimentsCss) { - if (experimentsValue && experimentsValue.kind() === "object") { - if (!findPair(experimentsValue, "css")) { - insertActions.push({ target: experimentsValue, buildProperties: () => ["css: true"] }); - } - } else if (!experimentsValue) { - topProperties.push((indent, unit) => - indent || unit - ? `experiments: {\n${indent}${unit}css: true,\n${indent}}` - : "experiments: { css: true }", - ); + private planConfigInsertions(): void { + for (const plan of this.configPlans.values()) { + const topProperties: ((indent: string, unit: string) => string)[] = []; + this.planExperimentsCss(plan, topProperties); + this.planOutputProps(plan, topProperties); + if (topProperties.length) { + this.topInsertTargets.add(plan.config.range().start.index); + this.insertActions.push({ + target: plan.config, + buildProperties: (indent, unit) => topProperties.map((build) => build(indent, unit)), + }); } } - if (plan.outputProps.length) { - const outputValue = findPair(plan.config, "output")?.field("value"); - const propTexts = plan.outputProps.map((prop) => `${prop.name}: ${prop.valueText}`); - if (outputValue && outputValue.kind() === "object") { - const missing = plan.outputProps - .filter((prop) => !findPair(outputValue, prop.name)) - .map((prop) => `${prop.name}: ${prop.valueText}`); - if (missing.length) { - insertActions.push({ target: outputValue, buildProperties: () => missing }); - } - } else if (!outputValue) { - topProperties.push((indent, unit) => - indent || unit - ? `output: {\n${propTexts.map((text) => `${indent}${unit}${text}`).join(",\n")},\n${indent}}` - : `output: { ${propTexts.join(", ")} }`, - ); + } + + private planExperimentsCss( + plan: ConfigPlan, + topProperties: ((indent: string, unit: string) => string)[], + ): void { + if (!plan.needsExperimentsCss) return; + const experimentsValue = findPair(plan.config, "experiments")?.field("value"); + if (experimentsValue && experimentsValue.kind() === "object") { + if (!findPair(experimentsValue, "css")) { + this.insertActions.push({ target: experimentsValue, buildProperties: () => ["css: true"] }); } - } - if (topProperties.length) { - topInsertTargets.add(plan.config.range().start.index); - insertActions.push({ - target: plan.config, - buildProperties: (indent, unit) => topProperties.map((build) => build(indent, unit)), - }); + } else if (!experimentsValue) { + topProperties.push((indent, unit) => + indent || unit + ? `experiments: {\n${indent}${unit}css: true,\n${indent}}` + : "experiments: { css: true }", + ); } } - finalizeRemovals(topInsertTargets); - - if (!edits.length) return null; - - // Drop the plugin import once no reference survives outside the edited ranges. - for (const binding of pluginBindings) { - const statementRange = rangeOf(binding.statement); - const survivingReference = rootNode - .findAll({ rule: { kind: "identifier" } }) - .some((identifier) => { - if (identifier.text() !== binding.name) return false; - const range = rangeOf(identifier); - if (range.start >= statementRange.start && range.end <= statementRange.end) return false; - return !isInsideAny(range, editedRanges); - }); - if (survivingReference) continue; - let end = statementRange.end; - if (source[end] === "\r") end += 1; - if (source[end] === "\n") end += 1; - // At the top of the file also swallow the blank line that separated it. - while (statementRange.start === 0 && (source[end] === "\n" || source[end] === "\r")) { - end += 1; + private planOutputProps( + plan: ConfigPlan, + topProperties: ((indent: string, unit: string) => string)[], + ): void { + if (!plan.outputProps.length) return; + const outputValue = findPair(plan.config, "output")?.field("value"); + const propTexts = plan.outputProps.map((prop) => `${prop.name}: ${prop.valueText}`); + if (outputValue && outputValue.kind() === "object") { + const missing = plan.outputProps + .filter((prop) => !findPair(outputValue, prop.name)) + .map((prop) => `${prop.name}: ${prop.valueText}`); + if (missing.length) { + this.insertActions.push({ target: outputValue, buildProperties: () => missing }); + } + } else if (!outputValue) { + topProperties.push((indent, unit) => + indent || unit + ? `output: {\n${propTexts.map((text) => `${indent}${unit}${text}`).join(",\n")},\n${indent}}` + : `output: { ${propTexts.join(", ")} }`, + ); } - edits.push({ startPos: statementRange.start, endPos: end, insertedText: "" }); } // Insert properties right after an object's opening brace, matching its layout. - const insertIntoObject = ( + private insertIntoObject( objectNode: SgNode, buildProperties: (indent: string, indentUnit: string) => string[], - ): void => { + ): void { const insertAt = objectNode.range().start.index + 1; const properties = namedChildren(objectNode); const multiline = objectNode.text().includes("\n") && properties.length > 0; let insertedText: string; if (multiline) { - const indent = lineIndent(source, properties[0].range().start.index); + const indent = lineIndent(this.source, properties[0].range().start.index); const indentUnit = indent.includes("\t") ? "\t" : indent || " "; insertedText = buildProperties(indent, indentUnit) .map((property) => `\n${indent}${property},`) @@ -508,14 +484,118 @@ async function transform(root: SgRoot): Promise { } else { insertedText = ` ${buildProperties("", "").join(", ")} `; } - edits.push({ startPos: insertAt, endPos: insertAt, insertedText }); - }; + this.edits.push({ startPos: insertAt, endPos: insertAt, insertedText }); + } - for (const action of insertActions) { - insertIntoObject(action.target, action.buildProperties); + // ---------- removals ---------- + + private removeText(range: Range): void { + this.edits.push({ startPos: range.start, endPos: range.end, insertedText: "" }); + this.editedRanges.push(range); } - return rootNode.commitEdits(edits); + private markForRemoval(node: SgNode): void { + const parent = node.parent(); + if (!parent) return; + const key = parent.range().start.index; + let group = this.pendingRemovals.get(key); + if (!group) { + group = { parent, removed: new Set() }; + this.pendingRemovals.set(key, group); + } + group.removed.add(node.range().start.index); + } + + private finalizeRemovals(): void { + for (const { parent, removed } of this.pendingRemovals.values()) { + const children = namedChildren(parent); + if (children.every((child) => removed.has(child.range().start.index))) { + this.clearContainer(parent, children); + continue; + } + this.removeChildRuns(children, removed); + } + } + + private clearContainer(parent: SgNode, children: SgNode[]): void { + if (this.topInsertTargets.has(parent.range().start.index) && children.length) { + // New properties will be inserted after "{" — clear the content only. + const first = children[0].range().start.index; + const lineStart = this.source.lastIndexOf("\n", first - 1) + 1; + this.removeText({ + start: lineStart > parent.range().start.index ? lineStart : first, + end: parent.range().end.index - 1, + }); + } else { + this.edits.push(parent.replace(parent.kind() === "array" ? "[]" : "{}")); + this.editedRanges.push(rangeOf(parent)); + } + } + + // Delete each contiguous run of removed children up to the next kept + // sibling, or back to the previous kept one for a trailing run. + private removeChildRuns(children: SgNode[], removed: Set): void { + let index = 0; + while (index < children.length) { + if (!removed.has(children[index].range().start.index)) { + index += 1; + continue; + } + let runEnd = index; + while ( + runEnd + 1 < children.length && + removed.has(children[runEnd + 1].range().start.index) + ) { + runEnd += 1; + } + const next = children[runEnd + 1]; + if (next) { + this.removeText({ + start: children[index].range().start.index, + end: next.range().start.index, + }); + } else { + this.removeText({ + start: children[index - 1].range().end.index, + end: children[runEnd].range().end.index, + }); + } + index = runEnd + 1; + } + } + + // ---------- imports ---------- + + // Drop the plugin import once no reference survives outside the edited ranges. + private removeUnusedImports(): void { + for (const binding of this.pluginBindings) { + const statementRange = rangeOf(binding.statement); + const survivingReference = this.rootNode + .findAll({ rule: { kind: "identifier" } }) + .some((identifier) => { + if (identifier.text() !== binding.name) return false; + const range = rangeOf(identifier); + if (range.start >= statementRange.start && range.end <= statementRange.end) return false; + return !isInsideAny(range, this.editedRanges); + }); + if (survivingReference) continue; + let end = statementRange.end; + if (this.source[end] === "\r") end += 1; + if (this.source[end] === "\n") end += 1; + // At the top of the file also swallow the blank line that separated it. + while ( + statementRange.start === 0 && + (this.source[end] === "\n" || this.source[end] === "\r") + ) { + end += 1; + } + this.edits.push({ startPos: statementRange.start, endPos: end, insertedText: "" }); + } + } +} + +async function transform(root: SgRoot): Promise { + return new CssMigration(root).run(); } export default transform; From 2fe1568bda0829a8e31ecf79746ff1d2315129d9 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:30:31 -0500 Subject: [PATCH 11/59] feat: preserve original filter predicate when rebuilding guarded use arrays --- .../css-plugins-to-native-css/src/workflow.ts | 29 ++++++++++++++----- .../tests/expected/custom-filter.config.js | 16 ++++++++++ .../tests/input/custom-filter.config.js | 16 ++++++++++ 3 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/custom-filter.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/custom-filter.config.js diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 8f4c288..556fce8 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -31,6 +31,7 @@ interface UseSwap { pair: SgNode; ruleObject: SgNode; keptLoaders: SgNode[]; + filterSuffix: string; } interface RulesArrayWork { @@ -83,15 +84,24 @@ function isInsideAny(range: Range, ranges: Range[]): boolean { return ranges.some((outer) => range.start >= outer.start && range.end <= outer.end); } -// `[ ... ].filter(Boolean)` — return the inner array literal. +// `[ ... ].filter()` — return the inner array literal. function unwrapFilterCall(node: SgNode): SgNode { if (node.kind() !== "call_expression") return node; const callee = node.field("function"); if (!callee || callee.kind() !== "member_expression") return node; + if (callee.field("property")?.text() !== "filter") return node; const receiver = callee.field("object"); return receiver && receiver.kind() === "array" ? receiver : node; } +// The `.filter(...)` text that followed the array, e.g. `.filter((x) => !!x)`. +function filterSuffixOf(originalValue: SgNode, arrayNode: SgNode): string { + if (originalValue.range().start.index === arrayNode.range().start.index) { + return originalValue.text().slice(arrayNode.text().length); + } + return ""; +} + // ---------- webpack-specific recognition ---------- // Loader name behind a `use` entry: a plain string, `require.resolve("...")`, @@ -278,9 +288,9 @@ class CssMigration { const rulesWork = new Map(); for (const pair of this.rootNode.findAll({ rule: { kind: "pair" } })) { if (keyName(pair) !== "use") continue; - let value = pair.field("value"); - if (!value) continue; - value = unwrapFilterCall(value); + const originalValue = pair.field("value"); + if (!originalValue) continue; + const value = unwrapFilterCall(originalValue); const elements = value.kind() === "array" ? namedChildren(value) : [value]; if (!elements.length) continue; const removable = elements.filter((element) => this.isRemovableUseElement(element)); @@ -304,7 +314,12 @@ class CssMigration { if (trivialRule && !kept.length && arrayNode.kind() === "array") { work.removedElements.push(ruleObject); } else { - work.swaps.push({ pair, ruleObject, keptLoaders: kept }); + work.swaps.push({ + pair, + ruleObject, + keptLoaders: kept, + filterSuffix: filterSuffixOf(originalValue, value), + }); } } return rulesWork; @@ -332,13 +347,13 @@ class CssMigration { private replaceUsePair(swap: UseSwap): void { if (swap.keptLoaders.length) { // Kept loaders stay in `use`; native CSS parses their output. Guarded - // entries keep the `.filter(Boolean)` that drops their falsy branch. + // entries keep the original `.filter(...)` that drops their falsy branch. const keptTexts = swap.keptLoaders.map((loader) => loader.text()); const keepsGuard = swap.keptLoaders.some( (loader) => loader.kind() === "binary_expression" || loader.kind() === "ternary_expression", ); - const filterSuffix = keepsGuard ? ".filter(Boolean)" : ""; + const filterSuffix = keepsGuard ? swap.filterSuffix || ".filter(Boolean)" : ""; const indent = lineIndent(this.source, swap.pair.range().start.index); const separator = swap.ruleObject.text().includes("\n") ? `,\n${indent}` : ", "; this.edits.push( diff --git a/codemods/css-plugins-to-native-css/tests/expected/custom-filter.config.js b/codemods/css-plugins-to-native-css/tests/expected/custom-filter.config.js new file mode 100644 index 0000000..3c0a07c --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/custom-filter.config.js @@ -0,0 +1,16 @@ +const isDev = process.env.NODE_ENV !== "production"; + +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + use: [isDev && "postcss-loader"].filter((loader) => !!loader), + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/custom-filter.config.js b/codemods/css-plugins-to-native-css/tests/input/custom-filter.config.js new file mode 100644 index 0000000..fb728f3 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/custom-filter.config.js @@ -0,0 +1,16 @@ +const isDev = process.env.NODE_ENV !== "production"; + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [ + isDev && "style-loader", + "css-loader", + isDev && "postcss-loader", + ].filter((loader) => !!loader), + }, + ], + }, +}; From 09662dddd53411e0732ec9fb7a7aaaf55b70c441 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:31:47 -0500 Subject: [PATCH 12/59] chore: add changeset --- .changeset/css-plugins-to-native-css.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/css-plugins-to-native-css.md diff --git a/.changeset/css-plugins-to-native-css.md b/.changeset/css-plugins-to-native-css.md new file mode 100644 index 0000000..68fee08 --- /dev/null +++ b/.changeset/css-plugins-to-native-css.md @@ -0,0 +1,5 @@ +--- +"@webpack/css-plugins-to-native-css": minor +--- + +Add codemod migrating mini-css-extract-plugin and style-loader/css-loader setups to webpack's native CSS support. From 13c565741f053106e3d21896ce768a2913702214 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:37:03 -0500 Subject: [PATCH 13/59] refactor: extract shared helpers into @webpack/codemod-utils workspace --- AGENTS.md | 4 + CONTRIBUTING.md | 2 +- .../css-plugins-to-native-css/package.json | 3 + .../css-plugins-to-native-css/src/workflow.ts | 344 +++--------------- package-lock.json | 18 +- package.json | 3 +- packages/codemod-utils/package.json | 27 ++ packages/codemod-utils/src/index.ts | 301 +++++++++++++++ tsconfig.json | 2 +- 9 files changed, 404 insertions(+), 300 deletions(-) create mode 100644 packages/codemod-utils/package.json create mode 100644 packages/codemod-utils/src/index.ts diff --git a/AGENTS.md b/AGENTS.md index 2e545a5..6b96bba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,10 @@ This repository hosts codemods that upgrade webpack configurations and APIs, pub | `npm run lint` / `npm run lint:fix` | ESLint check / autofix | | `npm run type-check` | `tsc --noEmit` over `src/` files | +## Shared utilities + +Logic reused across codemods lives in the `packages/codemod-utils/` workspace (`@webpack/codemod-utils`): generic ast-grep helpers (`findPair`, `namedChildren`, `unwrapFilterCall`, …), webpack-config helpers (`loaderNameOf`, `findConfigObjectFor`, `ruleMatchesFiles`, `collectModuleBindings`), and the `ConfigEditor` class (grouped removals, brace-aware insertion, unused-import cleanup). Import it from a codemod by adding `"@webpack/codemod-utils": "*"` to its `dependencies`; the jssg runner bundles it. Prefer extending it over copying helpers between codemods. + ## Creating a codemod Every codemod is a self-contained npm workspace under `codemods//`. Names are kebab-case and describe the migration (e.g. `hashed-module-ids-to-deterministic`); the published package is scoped as `@webpack/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec62bf2..68210b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ npm install ## Repository structure -Each codemod lives in its own directory under [`codemods/`](codemods/) and is an npm workspace: +Shared helpers used by several codemods live in [`packages/codemod-utils/`](packages/codemod-utils/) (`@webpack/codemod-utils`). Each codemod lives in its own directory under [`codemods/`](codemods/) and is an npm workspace: ```text codemods// diff --git a/codemods/css-plugins-to-native-css/package.json b/codemods/css-plugins-to-native-css/package.json index b646175..87cd1ce 100644 --- a/codemods/css-plugins-to-native-css/package.json +++ b/codemods/css-plugins-to-native-css/package.json @@ -16,6 +16,9 @@ "author": "bjohansebas (Sebastian Beltran)", "license": "MIT", "homepage": "https://github.com/webpack/codemods/blob/main/codemods/css-plugins-to-native-css/README.md", + "dependencies": { + "@webpack/codemod-utils": "*" + }, "devDependencies": { "@codemod.com/jssg-types": "^1.6.2" } diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 556fce8..c73a1dd 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -1,5 +1,20 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; -import type { Edit, SgNode, SgRoot } from "@codemod.com/jssg-types/main"; +import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; +import { + ConfigEditor, + type ModuleBinding, + collectModuleBindings, + filterSuffixOf, + findConfigObjectFor, + findPair, + keyName, + lineIndent, + loaderNameOf, + namedChildren, + pairsOf, + ruleMatchesFiles, + unwrapFilterCall, +} from "@webpack/codemod-utils"; const PLUGIN_MODULE = "mini-css-extract-plugin"; const REMOVABLE_LOADERS = new Set(["style-loader", "css-loader"]); @@ -9,16 +24,7 @@ const PLUGIN_OPTION_TO_OUTPUT = new Map([ ["filename", "cssFilename"], ["chunkFilename", "cssChunkFilename"], ]); - -interface Range { - start: number; - end: number; -} - -interface PluginBinding { - name: string; - statement: SgNode; -} +const CSS_SAMPLE_FILES = ["/file.css", "/file.module.css"]; // Properties to add to one webpack config object once all removals are known. interface ConfigPlan { @@ -45,128 +51,17 @@ interface InsertAction { buildProperties: (indent: string, indentUnit: string) => string[]; } -// ---------- generic AST helpers ---------- - -function rangeOf(node: SgNode): Range { - const range = node.range(); - return { start: range.start.index, end: range.end.index }; -} - -function unquote(text: string): string { - return text.replace(/^["'`]/, "").replace(/["'`]$/, ""); -} - -function namedChildren(node: SgNode): SgNode[] { - return node.children().filter((child) => child.isNamed()); -} - -function keyName(pair: SgNode): string | null { - const key = pair.field("key"); - return key ? unquote(key.text()) : null; -} - -function pairsOf(objectNode: SgNode): SgNode[] { - return namedChildren(objectNode).filter((child) => child.kind() === "pair"); -} - -function findPair(objectNode: SgNode, name: string): SgNode | undefined { - return pairsOf(objectNode).find((pair) => keyName(pair) === name); -} - -// Whitespace at the start of the line containing `index`. -function lineIndent(source: string, index: number): string { - const lineStart = source.lastIndexOf("\n", index - 1) + 1; - const match = /^[ \t]*/.exec(source.slice(lineStart, index)); - return match ? match[0] : ""; -} - -function isInsideAny(range: Range, ranges: Range[]): boolean { - return ranges.some((outer) => range.start >= outer.start && range.end <= outer.end); -} - -// `[ ... ].filter()` — return the inner array literal. -function unwrapFilterCall(node: SgNode): SgNode { - if (node.kind() !== "call_expression") return node; - const callee = node.field("function"); - if (!callee || callee.kind() !== "member_expression") return node; - if (callee.field("property")?.text() !== "filter") return node; - const receiver = callee.field("object"); - return receiver && receiver.kind() === "array" ? receiver : node; -} - -// The `.filter(...)` text that followed the array, e.g. `.filter((x) => !!x)`. -function filterSuffixOf(originalValue: SgNode, arrayNode: SgNode): string { - if (originalValue.range().start.index === arrayNode.range().start.index) { - return originalValue.text().slice(arrayNode.text().length); - } - return ""; -} - -// ---------- webpack-specific recognition ---------- - -// Loader name behind a `use` entry: a plain string, `require.resolve("...")`, -// or `{ loader: }`. -function loaderNameOf(node: SgNode): string | null { - if (node.kind() === "string") return unquote(node.text()); - if (node.kind() === "call_expression") { - const resolved = /^require\.resolve\(\s*(["'`][^"'`]+["'`])\s*\)$/.exec(node.text()); - return resolved ? unquote(resolved[1]) : null; - } - if (node.kind() !== "object") return null; - const loaderValue = findPair(node, "loader")?.field("value"); - return loaderValue ? loaderNameOf(loaderValue) : null; -} - -// Whether the rule still claims plain `.css` resources after the transform — -// if so it disables the `experiments.css: "auto"` default, which then needs -// an explicit `true`. Unreadable conditions count as matching, to be safe. -function ruleMatchesCssFiles(ruleObject: SgNode): boolean { - const testValue = findPair(ruleObject, "test")?.field("value"); - if (!testValue) return true; - if (testValue.kind() !== "regex") return true; - const literal = /^\/(.*)\/([a-z]*)$/s.exec(testValue.text()); - if (!literal) return true; - try { - const regex = new RegExp(literal[1], literal[2]); - return regex.test("/file.css") || regex.test("/file.module.css"); - } catch { - return true; - } -} - -// The enclosing webpack config object: nearest ancestor holding a `module` pair. -function findConfigForRule(node: SgNode): SgNode | null { - let current = node.parent(); - while (current) { - if (current.kind() === "pair" && keyName(current) === "module") { - const parent = current.parent(); - if (parent && parent.kind() === "object") return parent; - } - current = current.parent(); - } - return null; -} - class CssMigration { - private readonly rootNode: SgNode; - private readonly source: string; - private readonly pluginBindings: PluginBinding[] = []; + private readonly editor: ConfigEditor; + private readonly pluginBindings: ModuleBinding[]; private readonly pluginNames: Set; - private readonly edits: Edit[] = []; - private readonly editedRanges: Range[] = []; private readonly configPlans = new Map(); - // Removals of comma-separated list items are grouped per parent container so - // sibling removals in the same object/array never produce overlapping ranges. - private readonly pendingRemovals = new Map; removed: Set }>(); private readonly insertActions: InsertAction[] = []; - // Fully-emptied objects that must keep their braces open for new properties. - private readonly topInsertTargets = new Set(); constructor(root: SgRoot) { - this.rootNode = root.root(); - this.source = this.rootNode.text(); - this.collectPluginBindings(); + this.editor = new ConfigEditor(root.root()); + this.pluginBindings = collectModuleBindings(this.editor.rootNode, PLUGIN_MODULE); this.pluginNames = new Set(this.pluginBindings.map((binding) => binding.name)); } @@ -174,34 +69,18 @@ class CssMigration { this.transformRules(); this.transformPlugins(); this.planConfigInsertions(); - this.finalizeRemovals(); - if (!this.edits.length) return null; - this.removeUnusedImports(); + this.editor.finalizeRemovals(); + if (!this.editor.hasEdits) return null; + for (const binding of this.pluginBindings) { + this.editor.removeBindingIfUnused(binding); + } for (const action of this.insertActions) { - this.insertIntoObject(action.target, action.buildProperties); + this.editor.insertIntoObject(action.target, action.buildProperties); } - return this.rootNode.commitEdits(this.edits); + return this.editor.commit(); } - // ---------- plugin import detection ---------- - - private collectPluginBindings(): void { - const importPatterns = [ - "const $NAME = require($SOURCE)", - "let $NAME = require($SOURCE)", - "var $NAME = require($SOURCE)", - "import $NAME from $SOURCE", - ]; - for (const pattern of importPatterns) { - for (const statement of this.rootNode.findAll({ rule: { pattern } })) { - const name = statement.getMatch("NAME"); - const moduleSource = statement.getMatch("SOURCE"); - if (!name || !moduleSource) continue; - if (unquote(moduleSource.text()) !== PLUGIN_MODULE) continue; - this.pluginBindings.push({ name: name.text(), statement }); - } - } - } + // ---------- plugin recognition ---------- // `MiniCssExtractPlugin.loader` or `require("mini-css-extract-plugin").loader`. private isPluginLoaderExpression(node: SgNode): boolean { @@ -272,11 +151,11 @@ class CssMigration { for (const work of rulesWork.values()) { const allElements = namedChildren(work.arrayNode); if (work.removedElements.length === allElements.length && !work.swaps.length) { - this.markForRemoval(this.escalateEmptyRulesArray(work.arrayNode)); + this.editor.markForRemoval(this.escalateEmptyRulesArray(work.arrayNode)); continue; } for (const element of work.removedElements) { - this.markForRemoval(element); + this.editor.markForRemoval(element); } for (const swap of work.swaps) { this.replaceUsePair(swap); @@ -286,7 +165,7 @@ class CssMigration { private collectRulesWork(): Map { const rulesWork = new Map(); - for (const pair of this.rootNode.findAll({ rule: { kind: "pair" } })) { + for (const pair of this.editor.rootNode.findAll({ rule: { kind: "pair" } })) { if (keyName(pair) !== "use") continue; const originalValue = pair.field("value"); if (!originalValue) continue; @@ -354,27 +233,25 @@ class CssMigration { loader.kind() === "binary_expression" || loader.kind() === "ternary_expression", ); const filterSuffix = keepsGuard ? swap.filterSuffix || ".filter(Boolean)" : ""; - const indent = lineIndent(this.source, swap.pair.range().start.index); + const indent = lineIndent(this.editor.source, swap.pair.range().start.index); const separator = swap.ruleObject.text().includes("\n") ? `,\n${indent}` : ", "; - this.edits.push( - swap.pair.replace( - `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}type: "css/auto"`, - ), + this.editor.replace( + swap.pair, + `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}type: "css/auto"`, ); } else { - this.edits.push(swap.pair.replace('type: "css/auto"')); + this.editor.replace(swap.pair, 'type: "css/auto"'); } - this.editedRanges.push(rangeOf(swap.pair)); // A surviving rule that matches `.css` turns the "auto" default off. - if (!ruleMatchesCssFiles(swap.ruleObject)) return; - const config = findConfigForRule(swap.pair); + if (!ruleMatchesFiles(swap.ruleObject, CSS_SAMPLE_FILES)) return; + const config = findConfigObjectFor(swap.pair); if (config) this.planFor(config).needsExperimentsCss = true; } // ---------- plugins ---------- private transformPlugins(): void { - for (const pair of this.rootNode.findAll({ rule: { kind: "pair" } })) { + for (const pair of this.editor.rootNode.findAll({ rule: { kind: "pair" } })) { if (keyName(pair) !== "plugins") continue; let value = pair.field("value"); if (value) value = unwrapFilterCall(value); @@ -384,9 +261,9 @@ class CssMigration { if (!removed.length) continue; this.collectPluginOptions(pair, removed); if (removed.length === elements.length) { - this.markForRemoval(pair); + this.editor.markForRemoval(pair); } else { - for (const element of removed) this.markForRemoval(element); + for (const element of removed) this.editor.markForRemoval(element); } } } @@ -428,7 +305,8 @@ class CssMigration { this.planExperimentsCss(plan, topProperties); this.planOutputProps(plan, topProperties); if (topProperties.length) { - this.topInsertTargets.add(plan.config.range().start.index); + // A fully-emptied config keeps its braces open for these properties. + this.editor.keepBracesOpen(plan.config); this.insertActions.push({ target: plan.config, buildProperties: (indent, unit) => topProperties.map((build) => build(indent, unit)), @@ -445,7 +323,10 @@ class CssMigration { const experimentsValue = findPair(plan.config, "experiments")?.field("value"); if (experimentsValue && experimentsValue.kind() === "object") { if (!findPair(experimentsValue, "css")) { - this.insertActions.push({ target: experimentsValue, buildProperties: () => ["css: true"] }); + this.insertActions.push({ + target: experimentsValue, + buildProperties: () => ["css: true"], + }); } } else if (!experimentsValue) { topProperties.push((indent, unit) => @@ -478,135 +359,6 @@ class CssMigration { ); } } - - // Insert properties right after an object's opening brace, matching its layout. - private insertIntoObject( - objectNode: SgNode, - buildProperties: (indent: string, indentUnit: string) => string[], - ): void { - const insertAt = objectNode.range().start.index + 1; - const properties = namedChildren(objectNode); - const multiline = objectNode.text().includes("\n") && properties.length > 0; - let insertedText: string; - if (multiline) { - const indent = lineIndent(this.source, properties[0].range().start.index); - const indentUnit = indent.includes("\t") ? "\t" : indent || " "; - insertedText = buildProperties(indent, indentUnit) - .map((property) => `\n${indent}${property},`) - .join(""); - } else if (properties.length) { - insertedText = ` ${buildProperties("", "").join(", ")},`; - } else { - insertedText = ` ${buildProperties("", "").join(", ")} `; - } - this.edits.push({ startPos: insertAt, endPos: insertAt, insertedText }); - } - - // ---------- removals ---------- - - private removeText(range: Range): void { - this.edits.push({ startPos: range.start, endPos: range.end, insertedText: "" }); - this.editedRanges.push(range); - } - - private markForRemoval(node: SgNode): void { - const parent = node.parent(); - if (!parent) return; - const key = parent.range().start.index; - let group = this.pendingRemovals.get(key); - if (!group) { - group = { parent, removed: new Set() }; - this.pendingRemovals.set(key, group); - } - group.removed.add(node.range().start.index); - } - - private finalizeRemovals(): void { - for (const { parent, removed } of this.pendingRemovals.values()) { - const children = namedChildren(parent); - if (children.every((child) => removed.has(child.range().start.index))) { - this.clearContainer(parent, children); - continue; - } - this.removeChildRuns(children, removed); - } - } - - private clearContainer(parent: SgNode, children: SgNode[]): void { - if (this.topInsertTargets.has(parent.range().start.index) && children.length) { - // New properties will be inserted after "{" — clear the content only. - const first = children[0].range().start.index; - const lineStart = this.source.lastIndexOf("\n", first - 1) + 1; - this.removeText({ - start: lineStart > parent.range().start.index ? lineStart : first, - end: parent.range().end.index - 1, - }); - } else { - this.edits.push(parent.replace(parent.kind() === "array" ? "[]" : "{}")); - this.editedRanges.push(rangeOf(parent)); - } - } - - // Delete each contiguous run of removed children up to the next kept - // sibling, or back to the previous kept one for a trailing run. - private removeChildRuns(children: SgNode[], removed: Set): void { - let index = 0; - while (index < children.length) { - if (!removed.has(children[index].range().start.index)) { - index += 1; - continue; - } - let runEnd = index; - while ( - runEnd + 1 < children.length && - removed.has(children[runEnd + 1].range().start.index) - ) { - runEnd += 1; - } - const next = children[runEnd + 1]; - if (next) { - this.removeText({ - start: children[index].range().start.index, - end: next.range().start.index, - }); - } else { - this.removeText({ - start: children[index - 1].range().end.index, - end: children[runEnd].range().end.index, - }); - } - index = runEnd + 1; - } - } - - // ---------- imports ---------- - - // Drop the plugin import once no reference survives outside the edited ranges. - private removeUnusedImports(): void { - for (const binding of this.pluginBindings) { - const statementRange = rangeOf(binding.statement); - const survivingReference = this.rootNode - .findAll({ rule: { kind: "identifier" } }) - .some((identifier) => { - if (identifier.text() !== binding.name) return false; - const range = rangeOf(identifier); - if (range.start >= statementRange.start && range.end <= statementRange.end) return false; - return !isInsideAny(range, this.editedRanges); - }); - if (survivingReference) continue; - let end = statementRange.end; - if (this.source[end] === "\r") end += 1; - if (this.source[end] === "\n") end += 1; - // At the top of the file also swallow the blank line that separated it. - while ( - statementRange.start === 0 && - (this.source[end] === "\n" || this.source[end] === "\r") - ) { - end += 1; - } - this.edits.push({ startPos: statementRange.start, endPos: end, insertedText: "" }); - } - } } async function transform(root: SgRoot): Promise { diff --git a/package-lock.json b/package-lock.json index f75d81a..83e410a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "MIT", "workspaces": [ - "./codemods/*" + "./codemods/*", + "./packages/*" ], "devDependencies": { "@changesets/cli": "^2.31.1", @@ -24,6 +25,9 @@ "name": "@webpack/css-plugins-to-native-css", "version": "1.0.0", "license": "MIT", + "dependencies": { + "@webpack/codemod-utils": "*" + }, "devDependencies": { "@codemod.com/jssg-types": "^1.6.2" } @@ -1011,6 +1015,10 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@webpack/codemod-utils": { + "resolved": "packages/codemod-utils", + "link": true + }, "node_modules/@webpack/css-plugins-to-native-css": { "resolved": "codemods/css-plugins-to-native-css", "link": true @@ -2646,6 +2654,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "packages/codemod-utils": { + "name": "@webpack/codemod-utils", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.2" + } } } } diff --git a/package.json b/package.json index a7cccd4..58859c1 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "typescript-eslint": "^8.39.0" }, "workspaces": [ - "./codemods/*" + "./codemods/*", + "./packages/*" ] } diff --git a/packages/codemod-utils/package.json b/packages/codemod-utils/package.json new file mode 100644 index 0000000..88862bf --- /dev/null +++ b/packages/codemod-utils/package.json @@ -0,0 +1,27 @@ +{ + "name": "@webpack/codemod-utils", + "private": true, + "version": "1.0.0", + "description": "Shared ast-grep helpers for webpack codemods.", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "test": "node -e \"\"" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/webpack/codemods.git", + "directory": "packages/codemod-utils", + "bugs": "https://github.com/webpack/codemods/issues" + }, + "author": "bjohansebas (Sebastian Beltran)", + "license": "MIT", + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.2" + } +} diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts new file mode 100644 index 0000000..3a7f235 --- /dev/null +++ b/packages/codemod-utils/src/index.ts @@ -0,0 +1,301 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { Edit, SgNode } from "@codemod.com/jssg-types/main"; + +export interface Range { + start: number; + end: number; +} + +// A top-level `require`/`import` binding of a given module. +export interface ModuleBinding { + name: string; + statement: SgNode; +} + +// ---------- generic AST helpers ---------- + +export function rangeOf(node: SgNode): Range { + const range = node.range(); + return { start: range.start.index, end: range.end.index }; +} + +export function unquote(text: string): string { + return text.replace(/^["'`]/, "").replace(/["'`]$/, ""); +} + +export function namedChildren(node: SgNode): SgNode[] { + return node.children().filter((child) => child.isNamed()); +} + +export function keyName(pair: SgNode): string | null { + const key = pair.field("key"); + return key ? unquote(key.text()) : null; +} + +export function pairsOf(objectNode: SgNode): SgNode[] { + return namedChildren(objectNode).filter((child) => child.kind() === "pair"); +} + +export function findPair(objectNode: SgNode, name: string): SgNode | undefined { + return pairsOf(objectNode).find((pair) => keyName(pair) === name); +} + +// Whitespace at the start of the line containing `index`. +export function lineIndent(source: string, index: number): string { + const lineStart = source.lastIndexOf("\n", index - 1) + 1; + const match = /^[ \t]*/.exec(source.slice(lineStart, index)); + return match ? match[0] : ""; +} + +export function isInsideAny(range: Range, ranges: Range[]): boolean { + return ranges.some((outer) => range.start >= outer.start && range.end <= outer.end); +} + +// `[ ... ].filter()` — return the inner array literal. +export function unwrapFilterCall(node: SgNode): SgNode { + if (node.kind() !== "call_expression") return node; + const callee = node.field("function"); + if (!callee || callee.kind() !== "member_expression") return node; + if (callee.field("property")?.text() !== "filter") return node; + const receiver = callee.field("object"); + return receiver && receiver.kind() === "array" ? receiver : node; +} + +// The `.filter(...)` text that followed the array, e.g. `.filter((x) => !!x)`. +export function filterSuffixOf(originalValue: SgNode, arrayNode: SgNode): string { + if (originalValue.range().start.index === arrayNode.range().start.index) { + return originalValue.text().slice(arrayNode.text().length); + } + return ""; +} + +// ---------- webpack config helpers ---------- + +// Loader name behind a `use` entry: a plain string, `require.resolve("...")`, +// or `{ loader: }`. +export function loaderNameOf(node: SgNode): string | null { + if (node.kind() === "string") return unquote(node.text()); + if (node.kind() === "call_expression") { + const resolved = /^require\.resolve\(\s*(["'`][^"'`]+["'`])\s*\)$/.exec(node.text()); + return resolved ? unquote(resolved[1]) : null; + } + if (node.kind() !== "object") return null; + const loaderValue = findPair(node, "loader")?.field("value"); + return loaderValue ? loaderNameOf(loaderValue) : null; +} + +// Whether the rule claims one of the sample resource paths — used to know if it +// disables an `experiments.*: "auto"` default. Unreadable conditions count as +// matching, to be safe. +export function ruleMatchesFiles(ruleObject: SgNode, sampleFiles: string[]): boolean { + const testValue = findPair(ruleObject, "test")?.field("value"); + if (!testValue) return true; + if (testValue.kind() !== "regex") return true; + const literal = /^\/(.*)\/([a-z]*)$/s.exec(testValue.text()); + if (!literal) return true; + try { + const regex = new RegExp(literal[1], literal[2]); + return sampleFiles.some((file) => regex.test(file)); + } catch { + return true; + } +} + +// The enclosing webpack config object: nearest ancestor holding a `module` pair. +export function findConfigObjectFor(node: SgNode): SgNode | null { + let current = node.parent(); + while (current) { + if (current.kind() === "pair" && keyName(current) === "module") { + const parent = current.parent(); + if (parent && parent.kind() === "object") return parent; + } + current = current.parent(); + } + return null; +} + +// Top-level `require`/`import` bindings of the given module. +export function collectModuleBindings(rootNode: SgNode, moduleName: string): ModuleBinding[] { + const bindings: ModuleBinding[] = []; + const importPatterns = [ + "const $NAME = require($SOURCE)", + "let $NAME = require($SOURCE)", + "var $NAME = require($SOURCE)", + "import $NAME from $SOURCE", + ]; + for (const pattern of importPatterns) { + for (const statement of rootNode.findAll({ rule: { pattern } })) { + const name = statement.getMatch("NAME"); + const moduleSource = statement.getMatch("SOURCE"); + if (!name || !moduleSource) continue; + if (unquote(moduleSource.text()) !== moduleName) continue; + bindings.push({ name: name.text(), statement }); + } + } + return bindings; +} + +// ---------- edit collection ---------- + +// Accumulates edits over one config file: text removals grouped per parent +// container (so sibling removals never overlap), brace-aware property +// insertion, and unused-import cleanup. +export class ConfigEditor { + readonly rootNode: SgNode; + readonly source: string; + + private readonly edits: Edit[] = []; + private readonly editedRanges: Range[] = []; + private readonly pendingRemovals = new Map< + number, + { parent: SgNode; removed: Set } + >(); + // Fully-emptied objects that must keep their braces open for new properties. + private readonly keepOpenTargets = new Set(); + + constructor(rootNode: SgNode) { + this.rootNode = rootNode; + this.source = rootNode.text(); + } + + get hasEdits(): boolean { + return this.edits.length > 0; + } + + replace(node: SgNode, text: string): void { + this.edits.push(node.replace(text)); + this.editedRanges.push(rangeOf(node)); + } + + removeText(range: Range): void { + this.edits.push({ startPos: range.start, endPos: range.end, insertedText: "" }); + this.editedRanges.push(range); + } + + markForRemoval(node: SgNode): void { + const parent = node.parent(); + if (!parent) return; + const key = parent.range().start.index; + let group = this.pendingRemovals.get(key); + if (!group) { + group = { parent, removed: new Set() }; + this.pendingRemovals.set(key, group); + } + group.removed.add(node.range().start.index); + } + + keepBracesOpen(objectNode: SgNode): void { + this.keepOpenTargets.add(objectNode.range().start.index); + } + + finalizeRemovals(): void { + for (const { parent, removed } of this.pendingRemovals.values()) { + const children = namedChildren(parent); + if (children.every((child) => removed.has(child.range().start.index))) { + this.clearContainer(parent, children); + continue; + } + this.removeChildRuns(children, removed); + } + } + + // Insert properties right after an object's opening brace, matching its layout. + insertIntoObject( + objectNode: SgNode, + buildProperties: (indent: string, indentUnit: string) => string[], + ): void { + const insertAt = objectNode.range().start.index + 1; + const properties = namedChildren(objectNode); + const multiline = objectNode.text().includes("\n") && properties.length > 0; + let insertedText: string; + if (multiline) { + const indent = lineIndent(this.source, properties[0].range().start.index); + const indentUnit = indent.includes("\t") ? "\t" : indent || " "; + insertedText = buildProperties(indent, indentUnit) + .map((property) => `\n${indent}${property},`) + .join(""); + } else if (properties.length) { + insertedText = ` ${buildProperties("", "").join(", ")},`; + } else { + insertedText = ` ${buildProperties("", "").join(", ")} `; + } + this.edits.push({ startPos: insertAt, endPos: insertAt, insertedText }); + } + + // Drop the binding's statement once no reference survives outside the edited + // ranges. Call after finalizeRemovals so those ranges are complete. + removeBindingIfUnused(binding: ModuleBinding): void { + const statementRange = rangeOf(binding.statement); + const survivingReference = this.rootNode + .findAll({ rule: { kind: "identifier" } }) + .some((identifier) => { + if (identifier.text() !== binding.name) return false; + const range = rangeOf(identifier); + if (range.start >= statementRange.start && range.end <= statementRange.end) return false; + return !isInsideAny(range, this.editedRanges); + }); + if (survivingReference) return; + let end = statementRange.end; + if (this.source[end] === "\r") end += 1; + if (this.source[end] === "\n") end += 1; + // At the top of the file also swallow the blank line that separated it. + while ( + statementRange.start === 0 && + (this.source[end] === "\n" || this.source[end] === "\r") + ) { + end += 1; + } + this.edits.push({ startPos: statementRange.start, endPos: end, insertedText: "" }); + } + + commit(): string { + return this.rootNode.commitEdits(this.edits); + } + + private clearContainer(parent: SgNode, children: SgNode[]): void { + if (this.keepOpenTargets.has(parent.range().start.index) && children.length) { + // New properties will be inserted after "{" — clear the content only. + const first = children[0].range().start.index; + const lineStart = this.source.lastIndexOf("\n", first - 1) + 1; + this.removeText({ + start: lineStart > parent.range().start.index ? lineStart : first, + end: parent.range().end.index - 1, + }); + } else { + this.edits.push(parent.replace(parent.kind() === "array" ? "[]" : "{}")); + this.editedRanges.push(rangeOf(parent)); + } + } + + // Delete each contiguous run of removed children up to the next kept + // sibling, or back to the previous kept one for a trailing run. + private removeChildRuns(children: SgNode[], removed: Set): void { + let index = 0; + while (index < children.length) { + if (!removed.has(children[index].range().start.index)) { + index += 1; + continue; + } + let runEnd = index; + while ( + runEnd + 1 < children.length && + removed.has(children[runEnd + 1].range().start.index) + ) { + runEnd += 1; + } + const next = children[runEnd + 1]; + if (next) { + this.removeText({ + start: children[index].range().start.index, + end: next.range().start.index, + }); + } else { + this.removeText({ + start: children[index - 1].range().end.index, + end: children[runEnd].range().end.index, + }); + } + index = runEnd + 1; + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 3dba9b4..c1d3cb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,6 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true }, - "include": ["codemods/*/src/**/*.ts"], + "include": ["codemods/*/src/**/*.ts", "packages/*/src/**/*.ts"], "exclude": ["node_modules", "**/tests/**"] } From e33550beec3293558f452e6dd3eb34584bc5286e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:43:20 -0500 Subject: [PATCH 14/59] feat: guard css-loader modules option, cover function and webpack-merge configs --- codemods/css-plugins-to-native-css/README.md | 3 +- .../css-plugins-to-native-css/src/workflow.ts | 30 +++++++++++++++++++ .../tests/expected/css-modules-only.config.js | 1 + .../tests/expected/css-modules.config.js | 10 +++++++ .../tests/expected/function-config.config.js | 6 ++++ .../tests/expected/webpack-merge.config.js | 6 ++++ .../tests/input/css-modules-only.config.js | 10 +++++++ .../tests/input/css-modules.config.js | 10 +++++++ .../tests/input/function-config.config.js | 17 +++++++++++ .../tests/input/webpack-merge.config.js | 16 ++++++++++ 10 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/css-modules-only.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/expected/function-config.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/expected/webpack-merge.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/css-modules-only.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/css-modules.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/function-config.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/webpack-merge.config.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 9d673fa..5dfeea9 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -11,7 +11,8 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Any other loader in the chain (`sass-loader`, `less-loader`, `postcss-loader`, custom ones, …) keeps working in front of native CSS: it stays in `use` while the injection/extraction loaders are dropped, and the rule gets `type: "css/auto"`. - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. -- Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. +- Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. +- Rules using css-loader's `modules` option are left untouched when they match plain `.css` files: that option applies to every matched file, while `css/auto` only treats `*.module.*` names as CSS modules — migrating would silently change semantics. Rules scoped to `.module.css` migrate normally. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index c73a1dd..5708679 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -122,6 +122,27 @@ class CssMigration { return name !== null && REMOVABLE_LOADERS.has(name); } + // A `use` entry configuring css-loader's `modules` option (any value — + // `false` differs from the `css/auto` naming convention too). + private hasCssModulesOption(node: SgNode): boolean { + if (node.kind() === "binary_expression") { + const right = node.field("right"); + return right !== null && this.hasCssModulesOption(right); + } + if (node.kind() === "ternary_expression") { + const consequence = node.field("consequence"); + const alternative = node.field("alternative"); + return ( + (consequence !== null && this.hasCssModulesOption(consequence)) || + (alternative !== null && this.hasCssModulesOption(alternative)) + ); + } + if (node.kind() !== "object" || loaderNameOf(node) !== "css-loader") return false; + const optionsValue = findPair(node, "options")?.field("value"); + if (!optionsValue || optionsValue.kind() !== "object") return false; + return findPair(optionsValue, "modules") !== undefined; + } + // The `new MiniCssExtractPlugin(...)` behind a plugins element, unwrapping // the `isProd && new Plugin()` / `isDev ? false : new Plugin()` guards. private pluginInstantiationOf(element: SgNode): SgNode | null { @@ -178,6 +199,15 @@ class CssMigration { if (!removable.length) continue; const ruleObject = pair.parent(); if (!ruleObject || ruleObject.kind() !== "object") continue; + // css-loader's `modules` option applies to every matched file, while + // `css/auto` only treats `*.module.*` names as CSS modules — migrating a + // rule that also matches plain `.css` would silently change semantics. + if ( + elements.some((element) => this.hasCssModulesOption(element)) && + ruleMatchesFiles(ruleObject, ["/file.css"]) + ) { + continue; + } const arrayNode = ruleObject.parent(); if (!arrayNode) continue; const key = arrayNode.range().start.index; diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-modules-only.config.js b/codemods/css-plugins-to-native-css/tests/expected/css-modules-only.config.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/css-modules-only.config.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js b/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js new file mode 100644 index 0000000..970bd63 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { modules: true } }], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/expected/function-config.config.js b/codemods/css-plugins-to-native-css/tests/expected/function-config.config.js new file mode 100644 index 0000000..226cf2b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/function-config.config.js @@ -0,0 +1,6 @@ +module.exports = (env, argv) => ({ + output: { + cssFilename: "app.css", + }, + entry: "./src/index.js", +}); diff --git a/codemods/css-plugins-to-native-css/tests/expected/webpack-merge.config.js b/codemods/css-plugins-to-native-css/tests/expected/webpack-merge.config.js new file mode 100644 index 0000000..80ceacd --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/webpack-merge.config.js @@ -0,0 +1,6 @@ +const { merge } = require("webpack-merge"); +const common = require("./webpack.common.js"); + +module.exports = merge(common, { + mode: "production", +}); diff --git a/codemods/css-plugins-to-native-css/tests/input/css-modules-only.config.js b/codemods/css-plugins-to-native-css/tests/input/css-modules-only.config.js new file mode 100644 index 0000000..974b84e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/css-modules-only.config.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.module\.css$/, + use: ["style-loader", { loader: "css-loader", options: { modules: true } }], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/css-modules.config.js b/codemods/css-plugins-to-native-css/tests/input/css-modules.config.js new file mode 100644 index 0000000..970bd63 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/css-modules.config.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { modules: true } }], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/function-config.config.js b/codemods/css-plugins-to-native-css/tests/input/function-config.config.js new file mode 100644 index 0000000..e38edb0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/function-config.config.js @@ -0,0 +1,17 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = (env, argv) => ({ + entry: "./src/index.js", + module: { + rules: [ + { + test: /\.css$/i, + use: [ + argv.mode === "development" ? "style-loader" : MiniCssExtractPlugin.loader, + "css-loader", + ], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "app.css" })], +}); diff --git a/codemods/css-plugins-to-native-css/tests/input/webpack-merge.config.js b/codemods/css-plugins-to-native-css/tests/input/webpack-merge.config.js new file mode 100644 index 0000000..0f89daf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/webpack-merge.config.js @@ -0,0 +1,16 @@ +const { merge } = require("webpack-merge"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const common = require("./webpack.common.js"); + +module.exports = merge(common, { + mode: "production", + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}); From 669bb8d4b9076be92d21c3c5eebb0293cf0dcaf4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:45:51 -0500 Subject: [PATCH 15/59] feat: gate migration on css-loader semantic options (url, import, exportType) --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 39 +++++++++++-------- .../tests/expected/css-url-option.config.js | 10 +++++ .../tests/input/css-url-option.config.js | 10 +++++ 4 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/css-url-option.config.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 5dfeea9..093a04f 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -12,7 +12,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. -- Rules using css-loader's `modules` option are left untouched when they match plain `.css` files: that option applies to every matched file, while `css/auto` only treats `*.module.*` names as CSS modules — migrating would silently change semantics. Rules scoped to `.module.css` migrate normally. +- css-loader options are checked before migrating: `importLoaders`, `sourceMap`, and `esModule` are safely dropped (native CSS covers them), while `url`, `import`, `exportType`, or any other semantic option leaves the rule untouched — native CSS cannot replicate them. `modules` blocks migration only when the rule also matches plain `.css` files (that option applies to every matched file, while `css/auto` only treats `*.module.*` names as CSS modules); rules scoped to `.module.css` migrate normally. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 5708679..82cae4a 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -25,6 +25,10 @@ const PLUGIN_OPTION_TO_OUTPUT = new Map([ ["chunkFilename", "cssChunkFilename"], ]); const CSS_SAMPLE_FILES = ["/file.css", "/file.module.css"]; +// css-loader options that describe the loader chain or things native CSS +// handles on its own; every other option (url, import, exportType, …) changes +// parsing/export semantics that native CSS cannot replicate. +const DROPPABLE_CSS_LOADER_OPTIONS = new Set(["importLoaders", "sourceMap", "esModule"]); // Properties to add to one webpack config object once all removals are known. interface ConfigPlan { @@ -122,25 +126,34 @@ class CssMigration { return name !== null && REMOVABLE_LOADERS.has(name); } - // A `use` entry configuring css-loader's `modules` option (any value — - // `false` differs from the `css/auto` naming convention too). - private hasCssModulesOption(node: SgNode): boolean { + // css-loader options that native CSS cannot replicate block the rule's + // migration: any key outside the droppable set (url, import, exportType, …), + // and `modules` when the rule also matches plain `.css` files — that option + // applies to every matched file, while `css/auto` only treats `*.module.*` + // names as CSS modules. An unreadable options value blocks too, to be safe. + private cssLoaderBlocksMigration(node: SgNode, ruleObject: SgNode): boolean { if (node.kind() === "binary_expression") { const right = node.field("right"); - return right !== null && this.hasCssModulesOption(right); + return right !== null && this.cssLoaderBlocksMigration(right, ruleObject); } if (node.kind() === "ternary_expression") { const consequence = node.field("consequence"); const alternative = node.field("alternative"); return ( - (consequence !== null && this.hasCssModulesOption(consequence)) || - (alternative !== null && this.hasCssModulesOption(alternative)) + (consequence !== null && this.cssLoaderBlocksMigration(consequence, ruleObject)) || + (alternative !== null && this.cssLoaderBlocksMigration(alternative, ruleObject)) ); } if (node.kind() !== "object" || loaderNameOf(node) !== "css-loader") return false; - const optionsValue = findPair(node, "options")?.field("value"); - if (!optionsValue || optionsValue.kind() !== "object") return false; - return findPair(optionsValue, "modules") !== undefined; + const optionsPair = findPair(node, "options"); + if (!optionsPair) return false; + const optionsValue = optionsPair.field("value"); + if (!optionsValue || optionsValue.kind() !== "object") return true; + return pairsOf(optionsValue).some((optionPair) => { + const name = keyName(optionPair); + if (name === "modules") return ruleMatchesFiles(ruleObject, ["/file.css"]); + return name === null || !DROPPABLE_CSS_LOADER_OPTIONS.has(name); + }); } // The `new MiniCssExtractPlugin(...)` behind a plugins element, unwrapping @@ -199,13 +212,7 @@ class CssMigration { if (!removable.length) continue; const ruleObject = pair.parent(); if (!ruleObject || ruleObject.kind() !== "object") continue; - // css-loader's `modules` option applies to every matched file, while - // `css/auto` only treats `*.module.*` names as CSS modules — migrating a - // rule that also matches plain `.css` would silently change semantics. - if ( - elements.some((element) => this.hasCssModulesOption(element)) && - ruleMatchesFiles(ruleObject, ["/file.css"]) - ) { + if (elements.some((element) => this.cssLoaderBlocksMigration(element, ruleObject))) { continue; } const arrayNode = ruleObject.parent(); diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js b/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js new file mode 100644 index 0000000..868e416 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { url: false, import: false } }], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/css-url-option.config.js b/codemods/css-plugins-to-native-css/tests/input/css-url-option.config.js new file mode 100644 index 0000000..868e416 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/css-url-option.config.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { url: false, import: false } }], + }, + ], + }, +}; From 15d9e6d07113afe2f38fc77dffa089bb25bbc7f9 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:48:45 -0500 Subject: [PATCH 16/59] feat: migrate rules with non-replicable css-loader options, flagging them with a comment --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 67 ++++++++++++------- .../tests/expected/css-modules.config.js | 6 +- .../tests/expected/css-url-option.config.js | 6 +- 4 files changed, 52 insertions(+), 29 deletions(-) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 093a04f..705e25b 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -12,7 +12,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. -- css-loader options are checked before migrating: `importLoaders`, `sourceMap`, and `esModule` are safely dropped (native CSS covers them), while `url`, `import`, `exportType`, or any other semantic option leaves the rule untouched — native CSS cannot replicate them. `modules` blocks migration only when the rule also matches plain `.css` files (that option applies to every matched file, while `css/auto` only treats `*.module.*` names as CSS modules); rules scoped to `.module.css` migrate normally. +- css-loader options are checked while migrating: `importLoaders`, `sourceMap`, and `esModule` are dropped silently (native CSS covers them). Semantic options native CSS cannot replicate — `url`, `import`, `exportType`, or `modules` on a rule that also matches plain `.css` files — are dropped too, but the rule keeps a `// Removed css-loader options without a native CSS equivalent: …` comment so you can review the behavior change. `modules` on a rule scoped to `.module.css` matches the `css/auto` convention and migrates silently. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 82cae4a..a6505fb 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -42,6 +42,7 @@ interface UseSwap { ruleObject: SgNode; keptLoaders: SgNode[]; filterSuffix: string; + lostOptions: string[]; } interface RulesArrayWork { @@ -126,34 +127,39 @@ class CssMigration { return name !== null && REMOVABLE_LOADERS.has(name); } - // css-loader options that native CSS cannot replicate block the rule's - // migration: any key outside the droppable set (url, import, exportType, …), - // and `modules` when the rule also matches plain `.css` files — that option - // applies to every matched file, while `css/auto` only treats `*.module.*` - // names as CSS modules. An unreadable options value blocks too, to be safe. - private cssLoaderBlocksMigration(node: SgNode, ruleObject: SgNode): boolean { + // css-loader options native CSS cannot replicate: any key outside the + // droppable set (url, import, exportType, …), and `modules` when the rule + // also matches plain `.css` files — that option applies to every matched + // file, while `css/auto` only treats `*.module.*` names as CSS modules. + // These are still migrated, but flagged with a comment in the config. + private lostCssLoaderOptions(node: SgNode, ruleObject: SgNode): string[] { if (node.kind() === "binary_expression") { const right = node.field("right"); - return right !== null && this.cssLoaderBlocksMigration(right, ruleObject); + return right ? this.lostCssLoaderOptions(right, ruleObject) : []; } if (node.kind() === "ternary_expression") { const consequence = node.field("consequence"); const alternative = node.field("alternative"); - return ( - (consequence !== null && this.cssLoaderBlocksMigration(consequence, ruleObject)) || - (alternative !== null && this.cssLoaderBlocksMigration(alternative, ruleObject)) - ); + return [ + ...(consequence ? this.lostCssLoaderOptions(consequence, ruleObject) : []), + ...(alternative ? this.lostCssLoaderOptions(alternative, ruleObject) : []), + ]; } - if (node.kind() !== "object" || loaderNameOf(node) !== "css-loader") return false; + if (node.kind() !== "object" || loaderNameOf(node) !== "css-loader") return []; const optionsPair = findPair(node, "options"); - if (!optionsPair) return false; + if (!optionsPair) return []; const optionsValue = optionsPair.field("value"); - if (!optionsValue || optionsValue.kind() !== "object") return true; - return pairsOf(optionsValue).some((optionPair) => { + if (!optionsValue || optionsValue.kind() !== "object") return ["options"]; + const lost: string[] = []; + for (const optionPair of pairsOf(optionsValue)) { const name = keyName(optionPair); - if (name === "modules") return ruleMatchesFiles(ruleObject, ["/file.css"]); - return name === null || !DROPPABLE_CSS_LOADER_OPTIONS.has(name); - }); + if (name === "modules") { + if (ruleMatchesFiles(ruleObject, ["/file.css"])) lost.push(name); + } else if (name === null || !DROPPABLE_CSS_LOADER_OPTIONS.has(name)) { + lost.push(name ?? "options"); + } + } + return lost; } // The `new MiniCssExtractPlugin(...)` behind a plugins element, unwrapping @@ -212,9 +218,9 @@ class CssMigration { if (!removable.length) continue; const ruleObject = pair.parent(); if (!ruleObject || ruleObject.kind() !== "object") continue; - if (elements.some((element) => this.cssLoaderBlocksMigration(element, ruleObject))) { - continue; - } + const lostOptions = [ + ...new Set(elements.flatMap((element) => this.lostCssLoaderOptions(element, ruleObject))), + ]; const arrayNode = ruleObject.parent(); if (!arrayNode) continue; const key = arrayNode.range().start.index; @@ -227,7 +233,8 @@ class CssMigration { const name = keyName(rulePair); return name === "test" || name === "use"; }); - if (trivialRule && !kept.length && arrayNode.kind() === "array") { + // A rule with lost options stays as a swap so the comment has a home. + if (trivialRule && !kept.length && !lostOptions.length && arrayNode.kind() === "array") { work.removedElements.push(ruleObject); } else { work.swaps.push({ @@ -235,6 +242,7 @@ class CssMigration { ruleObject, keptLoaders: kept, filterSuffix: filterSuffixOf(originalValue, value), + lostOptions, }); } } @@ -261,6 +269,14 @@ class CssMigration { } private replaceUsePair(swap: UseSwap): void { + const indent = lineIndent(this.editor.source, swap.pair.range().start.index); + const multiline = swap.ruleObject.text().includes("\n"); + // Flag dropped css-loader options right where they lived. + let commentPrefix = ""; + if (swap.lostOptions.length) { + const message = `Removed css-loader options without a native CSS equivalent: ${swap.lostOptions.join(", ")}`; + commentPrefix = multiline ? `// ${message}\n${indent}` : `/* ${message} */ `; + } if (swap.keptLoaders.length) { // Kept loaders stay in `use`; native CSS parses their output. Guarded // entries keep the original `.filter(...)` that drops their falsy branch. @@ -270,14 +286,13 @@ class CssMigration { loader.kind() === "binary_expression" || loader.kind() === "ternary_expression", ); const filterSuffix = keepsGuard ? swap.filterSuffix || ".filter(Boolean)" : ""; - const indent = lineIndent(this.editor.source, swap.pair.range().start.index); - const separator = swap.ruleObject.text().includes("\n") ? `,\n${indent}` : ", "; + const separator = multiline ? `,\n${indent}` : ", "; this.editor.replace( swap.pair, - `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}type: "css/auto"`, + `${commentPrefix}use: [${keptTexts.join(", ")}]${filterSuffix}${separator}type: "css/auto"`, ); } else { - this.editor.replace(swap.pair, 'type: "css/auto"'); + this.editor.replace(swap.pair, `${commentPrefix}type: "css/auto"`); } // A surviving rule that matches `.css` turns the "auto" default off. if (!ruleMatchesFiles(swap.ruleObject, CSS_SAMPLE_FILES)) return; diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js b/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js index 970bd63..3f9e47f 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js @@ -1,9 +1,13 @@ module.exports = { + experiments: { + css: true, + }, module: { rules: [ { test: /\.css$/, - use: ["style-loader", { loader: "css-loader", options: { modules: true } }], + // Removed css-loader options without a native CSS equivalent: modules + type: "css/auto", }, ], }, diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js b/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js index 868e416..3cb5f65 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js @@ -1,9 +1,13 @@ module.exports = { + experiments: { + css: true, + }, module: { rules: [ { test: /\.css$/, - use: ["style-loader", { loader: "css-loader", options: { url: false, import: false } }], + // Removed css-loader options without a native CSS equivalent: url, import + type: "css/auto", }, ], }, From 9618f645be95e98b66ea9eeab940e70b0d86fed7 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:52:33 -0500 Subject: [PATCH 17/59] fix: cascade empty-container removal through oneOf rule nesting --- .../css-plugins-to-native-css/src/workflow.ts | 40 +++++++++++-------- .../tests/expected/one-of-only.config.js | 3 ++ .../tests/expected/one-of.config.js | 14 +++++++ .../tests/input/one-of-only.config.js | 18 +++++++++ .../tests/input/one-of.config.js | 21 ++++++++++ 5 files changed, 79 insertions(+), 17 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/one-of-only.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/expected/one-of.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/one-of-only.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/one-of.config.js diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index a6505fb..b9be2bb 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -191,7 +191,7 @@ class CssMigration { for (const work of rulesWork.values()) { const allElements = namedChildren(work.arrayNode); if (work.removedElements.length === allElements.length && !work.swaps.length) { - this.editor.markForRemoval(this.escalateEmptyRulesArray(work.arrayNode)); + this.editor.markForRemoval(this.cascadeRemovalTarget(work.arrayNode)); continue; } for (const element of work.removedElements) { @@ -249,23 +249,29 @@ class CssMigration { return rulesWork; } - // The whole rules array goes away — cascade to `rules`/`module` when empty. - private escalateEmptyRulesArray(arrayNode: SgNode): SgNode { - const rulesPair = arrayNode.parent(); - if (!rulesPair || rulesPair.kind() !== "pair") return arrayNode; - const moduleObject = rulesPair.parent(); - const modulePair = moduleObject ? moduleObject.parent() : null; - if ( - moduleObject && - moduleObject.kind() === "object" && - pairsOf(moduleObject).length === 1 && - modulePair && - modulePair.kind() === "pair" && - keyName(modulePair) === "module" - ) { - return modulePair; + // Climb while removing `target` would leave an empty container behind, so a + // css-only `oneOf` → rule → `rules` → `module` chain collapses as one + // removal. Stops where a container keeps other members (the grouped-removal + // machinery then deletes the target cleanly) or where the container is not a + // property value/list element (clearContainer then empties it in place). + private cascadeRemovalTarget(node: SgNode): SgNode { + let target = node; + for (;;) { + const parent = target.parent(); + if (!parent) return target; + if (parent.kind() === "pair") { + target = parent; + continue; + } + if (parent.kind() !== "object" && parent.kind() !== "array") return target; + const members = parent.kind() === "object" ? pairsOf(parent) : namedChildren(parent); + if (members.length !== 1) return target; + const grandparent = parent.parent(); + if (!grandparent || (grandparent.kind() !== "pair" && grandparent.kind() !== "array")) { + return target; + } + target = parent; } - return rulesPair; } private replaceUsePair(swap: UseSwap): void { diff --git a/codemods/css-plugins-to-native-css/tests/expected/one-of-only.config.js b/codemods/css-plugins-to-native-css/tests/expected/one-of-only.config.js new file mode 100644 index 0000000..e9bba3d --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/one-of-only.config.js @@ -0,0 +1,3 @@ +module.exports = { + mode: "production", +}; diff --git a/codemods/css-plugins-to-native-css/tests/expected/one-of.config.js b/codemods/css-plugins-to-native-css/tests/expected/one-of.config.js new file mode 100644 index 0000000..f346e18 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/one-of.config.js @@ -0,0 +1,14 @@ +module.exports = { + module: { + rules: [ + { + oneOf: [ + { + test: /\.js$/, + use: ["babel-loader"], + }, + ], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/one-of-only.config.js b/codemods/css-plugins-to-native-css/tests/input/one-of-only.config.js new file mode 100644 index 0000000..d016844 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/one-of-only.config.js @@ -0,0 +1,18 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + mode: "production", + module: { + rules: [ + { + oneOf: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/one-of.config.js b/codemods/css-plugins-to-native-css/tests/input/one-of.config.js new file mode 100644 index 0000000..08ec09b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/one-of.config.js @@ -0,0 +1,21 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + oneOf: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + { + test: /\.js$/, + use: ["babel-loader"], + }, + ], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; From a63b08da58d762dc06fc96191a948b3d5a991cf0 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 19:56:47 -0500 Subject: [PATCH 18/59] feat: flag dropped style-loader and extract-loader options with the same comment --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 75 +++++++++---------- .../tests/expected/cra-ejected.config.js | 12 +++ .../tests/expected/css-modules.config.js | 2 +- .../tests/expected/css-url-option.config.js | 2 +- 5 files changed, 51 insertions(+), 42 deletions(-) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 705e25b..85c9887 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -12,7 +12,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. -- css-loader options are checked while migrating: `importLoaders`, `sourceMap`, and `esModule` are dropped silently (native CSS covers them). Semantic options native CSS cannot replicate — `url`, `import`, `exportType`, or `modules` on a rule that also matches plain `.css` files — are dropped too, but the rule keeps a `// Removed css-loader options without a native CSS equivalent: …` comment so you can review the behavior change. `modules` on a rule scoped to `.module.css` matches the `css/auto` convention and migrates silently. +- Loader options are checked while migrating: `importLoaders`, `sourceMap`, and `esModule` are dropped silently (native CSS covers them). Semantic options native CSS cannot replicate — css-loader's `url`/`import`/`exportType`, style-loader's `insert`/`attributes`, `MiniCssExtractPlugin.loader`'s `publicPath`, or css-loader's `modules` on a rule that also matches plain `.css` files — are dropped too, but the rule keeps a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. `modules` on a rule scoped to `.module.css` matches the `css/auto` convention and migrates silently. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index b9be2bb..c49b8ae 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -18,17 +18,15 @@ import { const PLUGIN_MODULE = "mini-css-extract-plugin"; const REMOVABLE_LOADERS = new Set(["style-loader", "css-loader"]); -// Only `filename`/`chunkFilename` have native counterparts; the rest of the -// plugin options (ignoreOrder, insert, attributes, linkType, runtime) don't. +// Plugin options with a native counterpart; the rest have none and are dropped. const PLUGIN_OPTION_TO_OUTPUT = new Map([ ["filename", "cssFilename"], ["chunkFilename", "cssChunkFilename"], ]); const CSS_SAMPLE_FILES = ["/file.css", "/file.module.css"]; -// css-loader options that describe the loader chain or things native CSS -// handles on its own; every other option (url, import, exportType, …) changes -// parsing/export semantics that native CSS cannot replicate. +// Options native CSS covers on its own; any other option is flagged when dropped. const DROPPABLE_CSS_LOADER_OPTIONS = new Set(["importLoaders", "sourceMap", "esModule"]); +const DROPPABLE_INJECTION_LOADER_OPTIONS = new Set(["esModule"]); // Properties to add to one webpack config object once all removals are known. interface ConfigPlan { @@ -100,9 +98,7 @@ class CssMigration { ); } - // A `use` entry replaceable by native CSS: a known loader string, the - // plugin's `.loader`, `{ loader: , ... }`, or the dev/prod - // `cond ? a : b` / `cond && a` forms where every branch is replaceable. + // A `use` entry replaceable by native CSS, unwrapping dev/prod guards. private isRemovableUseElement(node: SgNode): boolean { if (this.isPluginLoaderExpression(node)) return true; if (node.kind() === "binary_expression" && node.field("operator")?.text() === "&&") { @@ -127,43 +123,50 @@ class CssMigration { return name !== null && REMOVABLE_LOADERS.has(name); } - // css-loader options native CSS cannot replicate: any key outside the - // droppable set (url, import, exportType, …), and `modules` when the rule - // also matches plain `.css` files — that option applies to every matched - // file, while `css/auto` only treats `*.module.*` names as CSS modules. - // These are still migrated, but flagged with a comment in the config. - private lostCssLoaderOptions(node: SgNode, ruleObject: SgNode): string[] { + // Dropped options native CSS cannot replicate, qualified per loader; + // `modules` counts only when the rule also matches plain `.css` files. + private lostLoaderOptions(node: SgNode, ruleObject: SgNode): string[] { if (node.kind() === "binary_expression") { const right = node.field("right"); - return right ? this.lostCssLoaderOptions(right, ruleObject) : []; + return right ? this.lostLoaderOptions(right, ruleObject) : []; } if (node.kind() === "ternary_expression") { const consequence = node.field("consequence"); const alternative = node.field("alternative"); return [ - ...(consequence ? this.lostCssLoaderOptions(consequence, ruleObject) : []), - ...(alternative ? this.lostCssLoaderOptions(alternative, ruleObject) : []), + ...(consequence ? this.lostLoaderOptions(consequence, ruleObject) : []), + ...(alternative ? this.lostLoaderOptions(alternative, ruleObject) : []), ]; } - if (node.kind() !== "object" || loaderNameOf(node) !== "css-loader") return []; + if (node.kind() !== "object") return []; + const loaderValue = findPair(node, "loader")?.field("value"); + const isExtractLoader = Boolean(loaderValue && this.isPluginLoaderExpression(loaderValue)); + const loaderName = isExtractLoader ? "MiniCssExtractPlugin.loader" : loaderNameOf(node); + if (!loaderName) return []; + if (!isExtractLoader && loaderName !== "css-loader" && loaderName !== "style-loader") { + return []; + } + const droppable = + loaderName === "css-loader" + ? DROPPABLE_CSS_LOADER_OPTIONS + : DROPPABLE_INJECTION_LOADER_OPTIONS; const optionsPair = findPair(node, "options"); if (!optionsPair) return []; const optionsValue = optionsPair.field("value"); - if (!optionsValue || optionsValue.kind() !== "object") return ["options"]; + if (!optionsValue || optionsValue.kind() !== "object") return [`${loaderName}.options`]; const lost: string[] = []; for (const optionPair of pairsOf(optionsValue)) { const name = keyName(optionPair); - if (name === "modules") { - if (ruleMatchesFiles(ruleObject, ["/file.css"])) lost.push(name); - } else if (name === null || !DROPPABLE_CSS_LOADER_OPTIONS.has(name)) { - lost.push(name ?? "options"); + if (loaderName === "css-loader" && name === "modules") { + if (ruleMatchesFiles(ruleObject, ["/file.css"])) lost.push(`${loaderName}.${name}`); + } else if (name === null || !droppable.has(name)) { + lost.push(`${loaderName}.${name ?? "options"}`); } } return lost; } - // The `new MiniCssExtractPlugin(...)` behind a plugins element, unwrapping - // the `isProd && new Plugin()` / `isDev ? false : new Plugin()` guards. + // The plugin instantiation behind a plugins element, unwrapping guards. private pluginInstantiationOf(element: SgNode): SgNode | null { const candidates: (SgNode | null)[] = [element]; if (element.kind() === "binary_expression" && element.field("operator")?.text() === "&&") { @@ -182,10 +185,8 @@ class CssMigration { // ---------- module.rules ---------- - // Rules holding only `test` + `use` are dropped outright: with no user rule - // matching `.css`, `experiments.css: "auto"` enables native CSS by itself. - // Rules that must stay get `type: "css/auto"` (plus `experiments.css: true` - // when they match `.css`, since their presence disables the "auto" default). + // Trivial rules are dropped (the `experiments.css: "auto"` default takes + // over); surviving rules get `type: "css/auto"`. private transformRules(): void { const rulesWork = this.collectRulesWork(); for (const work of rulesWork.values()) { @@ -219,7 +220,7 @@ class CssMigration { const ruleObject = pair.parent(); if (!ruleObject || ruleObject.kind() !== "object") continue; const lostOptions = [ - ...new Set(elements.flatMap((element) => this.lostCssLoaderOptions(element, ruleObject))), + ...new Set(elements.flatMap((element) => this.lostLoaderOptions(element, ruleObject))), ]; const arrayNode = ruleObject.parent(); if (!arrayNode) continue; @@ -249,11 +250,8 @@ class CssMigration { return rulesWork; } - // Climb while removing `target` would leave an empty container behind, so a - // css-only `oneOf` → rule → `rules` → `module` chain collapses as one - // removal. Stops where a container keeps other members (the grouped-removal - // machinery then deletes the target cleanly) or where the container is not a - // property value/list element (clearContainer then empties it in place). + // Climb while the removal would leave an empty container behind, so a + // css-only `oneOf` → rule → `rules` → `module` chain collapses as one removal. private cascadeRemovalTarget(node: SgNode): SgNode { let target = node; for (;;) { @@ -277,15 +275,14 @@ class CssMigration { private replaceUsePair(swap: UseSwap): void { const indent = lineIndent(this.editor.source, swap.pair.range().start.index); const multiline = swap.ruleObject.text().includes("\n"); - // Flag dropped css-loader options right where they lived. + // Flag dropped loader options right where they lived. let commentPrefix = ""; if (swap.lostOptions.length) { - const message = `Removed css-loader options without a native CSS equivalent: ${swap.lostOptions.join(", ")}`; + const message = `Removed loader options without a native CSS equivalent: ${swap.lostOptions.join(", ")}`; commentPrefix = multiline ? `// ${message}\n${indent}` : `/* ${message} */ `; } if (swap.keptLoaders.length) { - // Kept loaders stay in `use`; native CSS parses their output. Guarded - // entries keep the original `.filter(...)` that drops their falsy branch. + // Guarded entries keep the original `.filter(...)` for their falsy branch. const keptTexts = swap.keptLoaders.map((loader) => loader.text()); const keepsGuard = swap.keptLoaders.some( (loader) => diff --git a/codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js b/codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js index 6c19cac..4411bce 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js @@ -2,8 +2,20 @@ const isEnvDevelopment = process.env.NODE_ENV === "development"; const isEnvProduction = process.env.NODE_ENV === "production"; module.exports = { + experiments: { + css: true, + }, output: { cssFilename: "static/css/[name].[contenthash:8].css", cssChunkFilename: "static/css/[name].[contenthash:8].chunk.css", }, + module: { + rules: [ + { + test: /\.css$/, + // Removed loader options without a native CSS equivalent: MiniCssExtractPlugin.loader.publicPath + type: "css/auto", + }, + ], + }, }; diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js b/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js index 3f9e47f..7f071e8 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js @@ -6,7 +6,7 @@ module.exports = { rules: [ { test: /\.css$/, - // Removed css-loader options without a native CSS equivalent: modules + // Removed loader options without a native CSS equivalent: css-loader.modules type: "css/auto", }, ], diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js b/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js index 3cb5f65..57dba7d 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js @@ -6,7 +6,7 @@ module.exports = { rules: [ { test: /\.css$/, - // Removed css-loader options without a native CSS equivalent: url, import + // Removed loader options without a native CSS equivalent: css-loader.url, css-loader.import type: "css/auto", }, ], From ced12fba427e041eecb57e6059464c24e23582db Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 20:02:45 -0500 Subject: [PATCH 19/59] feat: only transform rules in webpack-owned config contexts --- codemods/css-plugins-to-native-css/README.md | 2 ++ .../css-plugins-to-native-css/src/workflow.ts | 21 ++++++++++++++++--- .../tests/expected/storybook-main.config.js | 10 +++++++++ .../tests/input/storybook-main.config.js | 10 +++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/storybook-main.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/storybook-main.config.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 85c9887..e6fd394 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -16,6 +16,8 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. +Only rules the file demonstrably owns as webpack config are transformed (a `rules`/`oneOf` array, a `module` block, or an import of `mini-css-extract-plugin`). Rule fragments pushed into another tool's webpack config — Storybook's `webpackFinal`, craco, and similar — are never modified: those tools ship their own webpack, where a partial migration would break the build. + ## Usage ```sh diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index c49b8ae..1dd3df4 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -219,11 +219,14 @@ class CssMigration { if (!removable.length) continue; const ruleObject = pair.parent(); if (!ruleObject || ruleObject.kind() !== "object") continue; + const arrayNode = ruleObject.parent(); + // Only touch rules the file demonstrably owns as webpack config — never + // fragments pushed into another tool's config (Storybook, craco, …). + if (!arrayNode || arrayNode.kind() !== "array") continue; + if (!this.isWebpackRuleContext(pair, arrayNode)) continue; const lostOptions = [ ...new Set(elements.flatMap((element) => this.lostLoaderOptions(element, ruleObject))), ]; - const arrayNode = ruleObject.parent(); - if (!arrayNode) continue; const key = arrayNode.range().start.index; let work = rulesWork.get(key); if (!work) { @@ -235,7 +238,7 @@ class CssMigration { return name === "test" || name === "use"; }); // A rule with lost options stays as a swap so the comment has a home. - if (trivialRule && !kept.length && !lostOptions.length && arrayNode.kind() === "array") { + if (trivialRule && !kept.length && !lostOptions.length) { work.removedElements.push(ruleObject); } else { work.swaps.push({ @@ -250,6 +253,18 @@ class CssMigration { return rulesWork; } + // Webpack owns the rule when its array hangs on a `rules`/`oneOf` pair, the + // config has a `module` ancestor, or the file imports the extract plugin. + private isWebpackRuleContext(usePair: SgNode, arrayNode: SgNode): boolean { + const listPair = arrayNode.parent(); + if (listPair && listPair.kind() === "pair") { + const name = keyName(listPair); + if (name === "rules" || name === "oneOf") return true; + } + if (this.pluginNames.size > 0) return true; + return findConfigObjectFor(usePair) !== null; + } + // Climb while the removal would leave an empty container behind, so a // css-only `oneOf` → rule → `rules` → `module` chain collapses as one removal. private cascadeRemovalTarget(node: SgNode): SgNode { diff --git a/codemods/css-plugins-to-native-css/tests/expected/storybook-main.config.js b/codemods/css-plugins-to-native-css/tests/expected/storybook-main.config.js new file mode 100644 index 0000000..cd16598 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/storybook-main.config.js @@ -0,0 +1,10 @@ +module.exports = { + stories: ["../src/**/*.stories.js"], + webpackFinal: async (config) => { + config.module.rules.push({ + test: /\.css$/, + use: ["style-loader", "css-loader"], + }); + return config; + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/storybook-main.config.js b/codemods/css-plugins-to-native-css/tests/input/storybook-main.config.js new file mode 100644 index 0000000..cd16598 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/storybook-main.config.js @@ -0,0 +1,10 @@ +module.exports = { + stories: ["../src/**/*.stories.js"], + webpackFinal: async (config) => { + config.module.rules.push({ + test: /\.css$/, + use: ["style-loader", "css-loader"], + }); + return config; + }, +}; From feae30794199a0cf6ca97b61ccb9197dbfc07d82 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 20:10:08 -0500 Subject: [PATCH 20/59] docs: correct rationale for skipping tool-managed webpack fragments --- codemods/css-plugins-to-native-css/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index e6fd394..913e608 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -16,7 +16,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. -Only rules the file demonstrably owns as webpack config are transformed (a `rules`/`oneOf` array, a `module` block, or an import of `mini-css-extract-plugin`). Rule fragments pushed into another tool's webpack config — Storybook's `webpackFinal`, craco, and similar — are never modified: those tools ship their own webpack, where a partial migration would break the build. +Only rules the file demonstrably owns as webpack config are transformed (a `rules`/`oneOf` array, a `module` block, or an import of `mini-css-extract-plugin`). Rule fragments pushed into another tool's webpack config — Storybook's `webpackFinal`, craco, and similar — are never modified: there the config is a mutated parameter with no literal object to receive `experiments.css: true`, and the tool's own base config registers CSS rules that keep the `"auto"` default off, so a partial migration would break the build. ## Usage From 707a9c6edd1880d4f8847faa673cc25f54ba5d8a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 20:22:09 -0500 Subject: [PATCH 21/59] fix: honor source line endings in generated edits and normalize repo to LF --- .gitattributes | 5 +++++ .../tests/expected/crlf.config.js | 14 ++++++++++++++ .../tests/input/crlf.config.js | 11 +++++++++++ packages/codemod-utils/src/index.ts | 13 +++++++++++-- 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 codemods/css-plugins-to-native-css/tests/expected/crlf.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/crlf.config.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b2cc86e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Checkout with LF everywhere so generated fixture output matches on Windows. +* text=auto eol=lf + +# CRLF fixtures exercise the editor's EOL detection — keep their bytes as-is. +codemods/*/tests/**/crlf.config.js -text diff --git a/codemods/css-plugins-to-native-css/tests/expected/crlf.config.js b/codemods/css-plugins-to-native-css/tests/expected/crlf.config.js new file mode 100644 index 0000000..4236804 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/crlf.config.js @@ -0,0 +1,14 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/crlf.config.js b/codemods/css-plugins-to-native-css/tests/input/crlf.config.js new file mode 100644 index 0000000..65c162f --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/crlf.config.js @@ -0,0 +1,11 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: ["style-loader", "css-loader"], + }, + ], + }, +}; diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index 3a7f235..04c5029 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -153,17 +153,22 @@ export class ConfigEditor { // Fully-emptied objects that must keep their braces open for new properties. private readonly keepOpenTargets = new Set(); + // Line ending of the source file; generated text must use it too. + readonly eol: string; + constructor(rootNode: SgNode) { this.rootNode = rootNode; this.source = rootNode.text(); + this.eol = this.source.includes("\r\n") ? "\r\n" : "\n"; } get hasEdits(): boolean { return this.edits.length > 0; } + // Replacement text may use "\n"; it is converted to the source's EOL. replace(node: SgNode, text: string): void { - this.edits.push(node.replace(text)); + this.edits.push(node.replace(text.split("\n").join(this.eol))); this.editedRanges.push(rangeOf(node)); } @@ -219,7 +224,11 @@ export class ConfigEditor { } else { insertedText = ` ${buildProperties("", "").join(", ")} `; } - this.edits.push({ startPos: insertAt, endPos: insertAt, insertedText }); + this.edits.push({ + startPos: insertAt, + endPos: insertAt, + insertedText: insertedText.split("\n").join(this.eol), + }); } // Drop the binding's statement once no reference survives outside the edited From f55097bbd79e7f74a89a45b77c8b7f4851796bda Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 20:25:25 -0500 Subject: [PATCH 22/59] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20sta?= =?UTF-8?q?rt=20at=200.0.0=20with=20major=20changeset,=20support=20compute?= =?UTF-8?q?d=20loader=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/css-plugins-to-native-css.md | 2 +- .../css-plugins-to-native-css/package.json | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 20 ++++++++++++++----- .../tests/expected/computed-loader.config.js | 1 + .../tests/input/computed-loader.config.js | 13 ++++++++++++ 5 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/computed-loader.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/computed-loader.config.js diff --git a/.changeset/css-plugins-to-native-css.md b/.changeset/css-plugins-to-native-css.md index 68fee08..184c376 100644 --- a/.changeset/css-plugins-to-native-css.md +++ b/.changeset/css-plugins-to-native-css.md @@ -1,5 +1,5 @@ --- -"@webpack/css-plugins-to-native-css": minor +"@webpack/css-plugins-to-native-css": major --- Add codemod migrating mini-css-extract-plugin and style-loader/css-loader setups to webpack's native CSS support. diff --git a/codemods/css-plugins-to-native-css/package.json b/codemods/css-plugins-to-native-css/package.json index 87cd1ce..ff82f95 100644 --- a/codemods/css-plugins-to-native-css/package.json +++ b/codemods/css-plugins-to-native-css/package.json @@ -1,7 +1,7 @@ { "name": "@webpack/css-plugins-to-native-css", "private": true, - "version": "1.0.0", + "version": "0.0.0", "description": "Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css).", "type": "module", "scripts": { diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 1dd3df4..f77183f 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -13,6 +13,7 @@ import { namedChildren, pairsOf, ruleMatchesFiles, + unquote, unwrapFilterCall, } from "@webpack/codemod-utils"; @@ -85,12 +86,21 @@ class CssMigration { // ---------- plugin recognition ---------- - // `MiniCssExtractPlugin.loader` or `require("mini-css-extract-plugin").loader`. + // The plugin's `.loader` in any access form: `MiniCssExtractPlugin.loader`, + // `MiniCssExtractPlugin["loader"]`, or `require("mini-css-extract-plugin").loader`. private isPluginLoaderExpression(node: SgNode): boolean { - if (node.kind() !== "member_expression") return false; - const objectPart = node.field("object"); - const propertyPart = node.field("property"); - if (!objectPart || !propertyPart || propertyPart.text() !== "loader") return false; + let objectPart: SgNode | null = null; + if (node.kind() === "member_expression") { + if (node.field("property")?.text() !== "loader") return false; + objectPart = node.field("object"); + } else if (node.kind() === "subscript_expression") { + const indexPart = node.field("index"); + if (!indexPart || indexPart.kind() !== "string" || unquote(indexPart.text()) !== "loader") { + return false; + } + objectPart = node.field("object"); + } + if (!objectPart) return false; if (objectPart.kind() === "identifier") return this.pluginNames.has(objectPart.text()); return ( objectPart.kind() === "call_expression" && diff --git a/codemods/css-plugins-to-native-css/tests/expected/computed-loader.config.js b/codemods/css-plugins-to-native-css/tests/expected/computed-loader.config.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/computed-loader.config.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/codemods/css-plugins-to-native-css/tests/input/computed-loader.config.js b/codemods/css-plugins-to-native-css/tests/input/computed-loader.config.js new file mode 100644 index 0000000..d5b9728 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/computed-loader.config.js @@ -0,0 +1,13 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin["loader"], "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; From 81cd075835cd6fc785acdeffc2d40d1f355f758a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 20:30:59 -0500 Subject: [PATCH 23/59] refactor: split import-binding utils into own module, handle parenthesized require --- AGENTS.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 6 +- .../expected/parenthesized-require.config.js | 5 + .../input/parenthesized-require.config.js | 13 +++ packages/codemod-utils/src/ast.ts | 62 +++++++++++ packages/codemod-utils/src/imports.ts | 63 +++++++++++ packages/codemod-utils/src/index.ts | 102 +++--------------- 7 files changed, 160 insertions(+), 93 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/parenthesized-require.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/parenthesized-require.config.js create mode 100644 packages/codemod-utils/src/ast.ts create mode 100644 packages/codemod-utils/src/imports.ts diff --git a/AGENTS.md b/AGENTS.md index 6b96bba..0cd0358 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This repository hosts codemods that upgrade webpack configurations and APIs, pub ## Shared utilities -Logic reused across codemods lives in the `packages/codemod-utils/` workspace (`@webpack/codemod-utils`): generic ast-grep helpers (`findPair`, `namedChildren`, `unwrapFilterCall`, …), webpack-config helpers (`loaderNameOf`, `findConfigObjectFor`, `ruleMatchesFiles`, `collectModuleBindings`), and the `ConfigEditor` class (grouped removals, brace-aware insertion, unused-import cleanup). Import it from a codemod by adding `"@webpack/codemod-utils": "*"` to its `dependencies`; the jssg runner bundles it. Prefer extending it over copying helpers between codemods. +Logic reused across codemods lives in the `packages/codemod-utils/` workspace (`@webpack/codemod-utils`), split into `ast.ts` (generic ast-grep helpers: `findPair`, `namedChildren`, `unwrapFilterCall`, …), `imports.ts` (structural `require`/`import` binding detection: `collectModuleBindings`, `requireCallSource`, `unwrapParens`), and `index.ts` (webpack-config helpers — `loaderNameOf`, `findConfigObjectFor`, `ruleMatchesFiles` — plus the `ConfigEditor` class: grouped removals, brace-aware insertion, EOL-aware output, unused-import cleanup). Import it from a codemod by adding `"@webpack/codemod-utils": "*"` to its `dependencies`; the jssg runner bundles it. Prefer extending it over copying helpers between codemods. ## Creating a codemod diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index f77183f..9219fe8 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -12,6 +12,7 @@ import { loaderNameOf, namedChildren, pairsOf, + requireCallSource, ruleMatchesFiles, unquote, unwrapFilterCall, @@ -102,10 +103,7 @@ class CssMigration { } if (!objectPart) return false; if (objectPart.kind() === "identifier") return this.pluginNames.has(objectPart.text()); - return ( - objectPart.kind() === "call_expression" && - /^require\(\s*["'`]mini-css-extract-plugin["'`]\s*\)$/.test(objectPart.text()) - ); + return requireCallSource(objectPart) === PLUGIN_MODULE; } // A `use` entry replaceable by native CSS, unwrapping dev/prod guards. diff --git a/codemods/css-plugins-to-native-css/tests/expected/parenthesized-require.config.js b/codemods/css-plugins-to-native-css/tests/expected/parenthesized-require.config.js new file mode 100644 index 0000000..4f4a82f --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/parenthesized-require.config.js @@ -0,0 +1,5 @@ +module.exports = { + output: { + cssFilename: "[name].css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/input/parenthesized-require.config.js b/codemods/css-plugins-to-native-css/tests/input/parenthesized-require.config.js new file mode 100644 index 0000000..7f9902f --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/parenthesized-require.config.js @@ -0,0 +1,13 @@ +const MiniCssExtractPlugin = (require)("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [(require)("mini-css-extract-plugin").loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "[name].css" })], +}; diff --git a/packages/codemod-utils/src/ast.ts b/packages/codemod-utils/src/ast.ts new file mode 100644 index 0000000..26379cd --- /dev/null +++ b/packages/codemod-utils/src/ast.ts @@ -0,0 +1,62 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; + +export interface Range { + start: number; + end: number; +} + +export function rangeOf(node: SgNode): Range { + const range = node.range(); + return { start: range.start.index, end: range.end.index }; +} + +export function unquote(text: string): string { + return text.replace(/^["'`]/, "").replace(/["'`]$/, ""); +} + +export function namedChildren(node: SgNode): SgNode[] { + return node.children().filter((child) => child.isNamed()); +} + +export function keyName(pair: SgNode): string | null { + const key = pair.field("key"); + return key ? unquote(key.text()) : null; +} + +export function pairsOf(objectNode: SgNode): SgNode[] { + return namedChildren(objectNode).filter((child) => child.kind() === "pair"); +} + +export function findPair(objectNode: SgNode, name: string): SgNode | undefined { + return pairsOf(objectNode).find((pair) => keyName(pair) === name); +} + +// Whitespace at the start of the line containing `index`. +export function lineIndent(source: string, index: number): string { + const lineStart = source.lastIndexOf("\n", index - 1) + 1; + const match = /^[ \t]*/.exec(source.slice(lineStart, index)); + return match ? match[0] : ""; +} + +export function isInsideAny(range: Range, ranges: Range[]): boolean { + return ranges.some((outer) => range.start >= outer.start && range.end <= outer.end); +} + +// `[ ... ].filter()` — return the inner array literal. +export function unwrapFilterCall(node: SgNode): SgNode { + if (node.kind() !== "call_expression") return node; + const callee = node.field("function"); + if (!callee || callee.kind() !== "member_expression") return node; + if (callee.field("property")?.text() !== "filter") return node; + const receiver = callee.field("object"); + return receiver && receiver.kind() === "array" ? receiver : node; +} + +// The `.filter(...)` text that followed the array, e.g. `.filter((x) => !!x)`. +export function filterSuffixOf(originalValue: SgNode, arrayNode: SgNode): string { + if (originalValue.range().start.index === arrayNode.range().start.index) { + return originalValue.text().slice(arrayNode.text().length); + } + return ""; +} diff --git a/packages/codemod-utils/src/imports.ts b/packages/codemod-utils/src/imports.ts new file mode 100644 index 0000000..b7d8a32 --- /dev/null +++ b/packages/codemod-utils/src/imports.ts @@ -0,0 +1,63 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; + +import { namedChildren, unquote } from "./ast"; + +// A top-level `require`/`import` binding of a given module. +export interface ModuleBinding { + name: string; + statement: SgNode; +} + +export function unwrapParens(node: SgNode): SgNode { + let current = node; + while (current.kind() === "parenthesized_expression") { + const inner = namedChildren(current)[0]; + if (!inner) return current; + current = inner; + } + return current; +} + +// The module name of a `require("...")` call — parenthesized forms like +// `(require)("...")` included — or null when the node is anything else. +export function requireCallSource(node: SgNode): string | null { + const call = unwrapParens(node); + if (call.kind() !== "call_expression") return null; + const callee = call.field("function"); + if (!callee || unwrapParens(callee).text() !== "require") return null; + const argumentsNode = call.field("arguments"); + const args = argumentsNode ? namedChildren(argumentsNode) : []; + if (args.length !== 1 || args[0].kind() !== "string") return null; + return unquote(args[0].text()); +} + +// Top-level `require`/`import` bindings of the given module, matched +// structurally so aliased/parenthesized forms are covered too. +export function collectModuleBindings(rootNode: SgNode, moduleName: string): ModuleBinding[] { + const bindings: ModuleBinding[] = []; + for (const statement of rootNode.findAll({ rule: { kind: "import_statement" } })) { + const source = statement.field("source"); + if (!source || unquote(source.text()) !== moduleName) continue; + const clause = statement.children().find((child) => child.kind() === "import_clause"); + const defaultImport = clause + ? namedChildren(clause).find((child) => child.kind() === "identifier") + : undefined; + if (defaultImport) bindings.push({ name: defaultImport.text(), statement }); + } + for (const declarator of rootNode.findAll({ rule: { kind: "variable_declarator" } })) { + const name = declarator.field("name"); + const value = declarator.field("value"); + if (!name || name.kind() !== "identifier" || !value) continue; + if (requireCallSource(value) !== moduleName) continue; + const statement = declarator.parent(); + if (!statement) continue; + // Multi-declarator statements can't be removed wholesale — leave them alone. + const declaratorCount = namedChildren(statement).filter( + (child) => child.kind() === "variable_declarator", + ).length; + if (declaratorCount !== 1) continue; + bindings.push({ name: name.text(), statement }); + } + return bindings; +} diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index 04c5029..7a725b9 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -1,73 +1,20 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { Edit, SgNode } from "@codemod.com/jssg-types/main"; -export interface Range { - start: number; - end: number; -} - -// A top-level `require`/`import` binding of a given module. -export interface ModuleBinding { - name: string; - statement: SgNode; -} - -// ---------- generic AST helpers ---------- - -export function rangeOf(node: SgNode): Range { - const range = node.range(); - return { start: range.start.index, end: range.end.index }; -} - -export function unquote(text: string): string { - return text.replace(/^["'`]/, "").replace(/["'`]$/, ""); -} - -export function namedChildren(node: SgNode): SgNode[] { - return node.children().filter((child) => child.isNamed()); -} - -export function keyName(pair: SgNode): string | null { - const key = pair.field("key"); - return key ? unquote(key.text()) : null; -} - -export function pairsOf(objectNode: SgNode): SgNode[] { - return namedChildren(objectNode).filter((child) => child.kind() === "pair"); -} - -export function findPair(objectNode: SgNode, name: string): SgNode | undefined { - return pairsOf(objectNode).find((pair) => keyName(pair) === name); -} - -// Whitespace at the start of the line containing `index`. -export function lineIndent(source: string, index: number): string { - const lineStart = source.lastIndexOf("\n", index - 1) + 1; - const match = /^[ \t]*/.exec(source.slice(lineStart, index)); - return match ? match[0] : ""; -} - -export function isInsideAny(range: Range, ranges: Range[]): boolean { - return ranges.some((outer) => range.start >= outer.start && range.end <= outer.end); -} - -// `[ ... ].filter()` — return the inner array literal. -export function unwrapFilterCall(node: SgNode): SgNode { - if (node.kind() !== "call_expression") return node; - const callee = node.field("function"); - if (!callee || callee.kind() !== "member_expression") return node; - if (callee.field("property")?.text() !== "filter") return node; - const receiver = callee.field("object"); - return receiver && receiver.kind() === "array" ? receiver : node; -} - -// The `.filter(...)` text that followed the array, e.g. `.filter((x) => !!x)`. -export function filterSuffixOf(originalValue: SgNode, arrayNode: SgNode): string { - if (originalValue.range().start.index === arrayNode.range().start.index) { - return originalValue.text().slice(arrayNode.text().length); - } - return ""; -} +import { + type Range, + findPair, + isInsideAny, + keyName, + lineIndent, + namedChildren, + rangeOf, + unquote, +} from "./ast"; +import type { ModuleBinding } from "./imports"; + +export * from "./ast"; +export * from "./imports"; // ---------- webpack config helpers ---------- @@ -114,27 +61,6 @@ export function findConfigObjectFor(node: SgNode): SgNode | null { return null; } -// Top-level `require`/`import` bindings of the given module. -export function collectModuleBindings(rootNode: SgNode, moduleName: string): ModuleBinding[] { - const bindings: ModuleBinding[] = []; - const importPatterns = [ - "const $NAME = require($SOURCE)", - "let $NAME = require($SOURCE)", - "var $NAME = require($SOURCE)", - "import $NAME from $SOURCE", - ]; - for (const pattern of importPatterns) { - for (const statement of rootNode.findAll({ rule: { pattern } })) { - const name = statement.getMatch("NAME"); - const moduleSource = statement.getMatch("SOURCE"); - if (!name || !moduleSource) continue; - if (unquote(moduleSource.text()) !== moduleName) continue; - bindings.push({ name: name.text(), statement }); - } - } - return bindings; -} - // ---------- edit collection ---------- // Accumulates edits over one config file: text removals grouped per parent From b666e8bfca2d5b915b336ae71a2801a60c2d49ff Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 20:33:14 -0500 Subject: [PATCH 24/59] fix: use Name author format --- AGENTS.md | 2 +- codemods/css-plugins-to-native-css/package.json | 2 +- packages/codemod-utils/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0cd0358..131b900 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ Add `semantic_analysis: file` under `js-ast-grep` when the transform needs scope "directory": "codemods/", "bugs": "https://github.com/webpack/codemods/issues" }, - "author": " ()", + "author": " <>", "license": "MIT", "homepage": "https://github.com/webpack/codemods/blob/main/codemods//README.md", "devDependencies": { diff --git a/codemods/css-plugins-to-native-css/package.json b/codemods/css-plugins-to-native-css/package.json index ff82f95..36443bb 100644 --- a/codemods/css-plugins-to-native-css/package.json +++ b/codemods/css-plugins-to-native-css/package.json @@ -13,7 +13,7 @@ "directory": "codemods/css-plugins-to-native-css", "bugs": "https://github.com/webpack/codemods/issues" }, - "author": "bjohansebas (Sebastian Beltran)", + "author": "Sebastian Beltran ", "license": "MIT", "homepage": "https://github.com/webpack/codemods/blob/main/codemods/css-plugins-to-native-css/README.md", "dependencies": { diff --git a/packages/codemod-utils/package.json b/packages/codemod-utils/package.json index 88862bf..a11ed16 100644 --- a/packages/codemod-utils/package.json +++ b/packages/codemod-utils/package.json @@ -19,7 +19,7 @@ "directory": "packages/codemod-utils", "bugs": "https://github.com/webpack/codemods/issues" }, - "author": "bjohansebas (Sebastian Beltran)", + "author": "Sebastian Beltran ", "license": "MIT", "devDependencies": { "@codemod.com/jssg-types": "^1.6.2" From 826ea37f2cccb64a9a6fff1453cc93d7536548e9 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 20:33:49 -0500 Subject: [PATCH 25/59] chore: drop dummy test script in favor of --if-present --- package.json | 2 +- packages/codemod-utils/package.json | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/package.json b/package.json index 58859c1..7edd403 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "npm run test --workspaces", + "test": "npm run test --workspaces --if-present", "type-check": "tsc --noEmit" }, "repository": { diff --git a/packages/codemod-utils/package.json b/packages/codemod-utils/package.json index a11ed16..adea19e 100644 --- a/packages/codemod-utils/package.json +++ b/packages/codemod-utils/package.json @@ -10,9 +10,6 @@ "default": "./src/index.ts" } }, - "scripts": { - "test": "node -e \"\"" - }, "repository": { "type": "git", "url": "git+https://github.com/webpack/codemods.git", From 72c5d41c83fbcfe057c9da8d99adf6b96f10e6f1 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 20:39:46 -0500 Subject: [PATCH 26/59] feat: sync codemod.yaml versions in the changesets version step --- .github/scripts/sync-codemod-versions.mjs | 20 +++++++++++++++++++ .github/workflows/publish.yml | 11 +++++++++- AGENTS.md | 2 ++ .../css-plugins-to-native-css/codemod.yaml | 2 +- package-lock.json | 4 ++-- package.json | 3 ++- packages/codemod-utils/package.json | 2 +- 7 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/sync-codemod-versions.mjs diff --git a/.github/scripts/sync-codemod-versions.mjs b/.github/scripts/sync-codemod-versions.mjs new file mode 100644 index 0000000..e235915 --- /dev/null +++ b/.github/scripts/sync-codemod-versions.mjs @@ -0,0 +1,20 @@ +// Keeps each codemod.yaml `version` in sync with the package.json version +// bumped by `changeset version`. Run as part of the root `version` script. +import console from "node:console"; +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const codemodsDir = "codemods"; +for (const entry of readdirSync(codemodsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const packagePath = join(codemodsDir, entry.name, "package.json"); + const manifestPath = join(codemodsDir, entry.name, "codemod.yaml"); + if (!existsSync(packagePath) || !existsSync(manifestPath)) continue; + const { version } = JSON.parse(readFileSync(packagePath, "utf8")); + const manifest = readFileSync(manifestPath, "utf8"); + const updated = manifest.replace(/^version: .*$/m, `version: "${version}"`); + if (updated !== manifest) { + writeFileSync(manifestPath, updated); + console.log(`${manifestPath} -> ${version}`); + } +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d5e705e..3bbf134 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,6 +41,7 @@ jobs: id: changesets uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: + version: npm run version publish: npx changeset tag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -92,9 +93,17 @@ jobs: }); ') echo "path=$DIR" >> "$GITHUB_OUTPUT" - echo "Publishing $PKG_NAME from $DIR" + # Internal packages (no codemod.yaml) are versioned but not published. + if [ -f "$DIR/codemod.yaml" ]; then + echo "has_manifest=true" >> "$GITHUB_OUTPUT" + echo "Publishing $PKG_NAME from $DIR" + else + echo "has_manifest=false" >> "$GITHUB_OUTPUT" + echo "Skipping $PKG_NAME ($DIR has no codemod.yaml)" + fi - name: Publish to Codemod registry + if: steps.dir.outputs.has_manifest == 'true' uses: codemod/publish-action@dd6c8dbc5ceb1a6146feba41481d88b43da50024 # v1 with: path: ${{ steps.dir.outputs.path }} diff --git a/AGENTS.md b/AGENTS.md index 131b900..3867434 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,8 @@ This repository hosts codemods that upgrade webpack configurations and APIs, pub Logic reused across codemods lives in the `packages/codemod-utils/` workspace (`@webpack/codemod-utils`), split into `ast.ts` (generic ast-grep helpers: `findPair`, `namedChildren`, `unwrapFilterCall`, …), `imports.ts` (structural `require`/`import` binding detection: `collectModuleBindings`, `requireCallSource`, `unwrapParens`), and `index.ts` (webpack-config helpers — `loaderNameOf`, `findConfigObjectFor`, `ruleMatchesFiles` — plus the `ConfigEditor` class: grouped removals, brace-aware insertion, EOL-aware output, unused-import cleanup). Import it from a codemod by adding `"@webpack/codemod-utils": "*"` to its `dependencies`; the jssg runner bundles it. Prefer extending it over copying helpers between codemods. +The utils package is internal: it is never published and stays at version `0.0.0`. When a change to it affects released codemods, add a changeset for **each affected codemod** (they bundle the utils, so they are what needs re-publishing). + ## Creating a codemod Every codemod is a self-contained npm workspace under `codemods//`. Names are kebab-case and describe the migration (e.g. `hashed-module-ids-to-deterministic`); the published package is scoped as `@webpack/`. diff --git a/codemods/css-plugins-to-native-css/codemod.yaml b/codemods/css-plugins-to-native-css/codemod.yaml index 1f56e8e..ee4df34 100644 --- a/codemods/css-plugins-to-native-css/codemod.yaml +++ b/codemods/css-plugins-to-native-css/codemod.yaml @@ -1,6 +1,6 @@ schema_version: "1.0" name: "@webpack/css-plugins-to-native-css" -version: "1.0.0" +version: "0.0.0" description: Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css) author: bjohansebas (Sebastian Beltran) license: MIT diff --git a/package-lock.json b/package-lock.json index 83e410a..a6d176c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ }, "codemods/css-plugins-to-native-css": { "name": "@webpack/css-plugins-to-native-css", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "dependencies": { "@webpack/codemod-utils": "*" @@ -2657,7 +2657,7 @@ }, "packages/codemod-utils": { "name": "@webpack/codemod-utils", - "version": "1.0.0", + "version": "0.0.0", "license": "MIT", "devDependencies": { "@codemod.com/jssg-types": "^1.6.2" diff --git a/package.json b/package.json index 7edd403..ae55058 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "npm run test --workspaces --if-present", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "version": "changeset version && node .github/scripts/sync-codemod-versions.mjs" }, "repository": { "type": "git", diff --git a/packages/codemod-utils/package.json b/packages/codemod-utils/package.json index adea19e..e042081 100644 --- a/packages/codemod-utils/package.json +++ b/packages/codemod-utils/package.json @@ -1,7 +1,7 @@ { "name": "@webpack/codemod-utils", "private": true, - "version": "1.0.0", + "version": "0.0.0", "description": "Shared ast-grep helpers for webpack codemods.", "type": "module", "exports": { From b1fbdfe81a700729545a910cd9ab3a871f65fb0f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 20:41:35 -0500 Subject: [PATCH 27/59] ci: require codemod changesets when shared utils change --- .github/scripts/check-utils-changesets.mjs | 42 ++++++++++++++++++++++ .github/scripts/sync-codemod-versions.mjs | 1 - .github/workflows/ci.yml | 8 +++++ AGENTS.md | 2 +- eslint.config.mjs | 9 +++++ 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/check-utils-changesets.mjs diff --git a/.github/scripts/check-utils-changesets.mjs b/.github/scripts/check-utils-changesets.mjs new file mode 100644 index 0000000..108c7d5 --- /dev/null +++ b/.github/scripts/check-utils-changesets.mjs @@ -0,0 +1,42 @@ +// The utils package is internal and never published: when its source changes, +// the codemods that bundle it must be re-released. Fail the PR unless at least +// one dependent codemod is covered by a pending changeset. +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const base = process.argv[2] ?? "origin/main"; +const changed = execFileSync("git", ["diff", "--name-only", base, "HEAD"], { encoding: "utf8" }) + .split("\n") + .filter(Boolean); + +if (!changed.some((file) => file.startsWith("packages/codemod-utils/src/"))) { + process.exit(0); +} + +const dependents = []; +for (const entry of readdirSync("codemods", { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const packagePath = join("codemods", entry.name, "package.json"); + if (!existsSync(packagePath)) continue; + const pkg = JSON.parse(readFileSync(packagePath, "utf8")); + if (pkg.dependencies?.["@webpack/codemod-utils"]) dependents.push(pkg.name); +} +if (!dependents.length) process.exit(0); + +const covered = new Set(); +for (const file of readdirSync(".changeset")) { + if (!file.endsWith(".md") || file === "README.md") continue; + const content = readFileSync(join(".changeset", file), "utf8"); + for (const name of dependents) { + if (content.includes(`"${name}"`)) covered.add(name); + } +} +if (covered.size) process.exit(0); + +console.error( + "packages/codemod-utils changed, but no changeset bumps a dependent codemod.\n" + + "The utils are bundled into the published codemods, so add a changeset for the affected ones:\n" + + dependents.map((name) => ` - ${name}`).join("\n"), +); +process.exit(1); diff --git a/.github/scripts/sync-codemod-versions.mjs b/.github/scripts/sync-codemod-versions.mjs index e235915..b86013b 100644 --- a/.github/scripts/sync-codemod-versions.mjs +++ b/.github/scripts/sync-codemod-versions.mjs @@ -1,6 +1,5 @@ // Keeps each codemod.yaml `version` in sync with the package.json version // bumped by `changeset version`. Run as part of the root `version` script. -import console from "node:console"; import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9d3241..8f414ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,14 @@ jobs: - name: Run type check run: npm run type-check + - name: Check utils changes have codemod changesets + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + git fetch --no-tags --depth=1 origin "$BASE_REF" + node .github/scripts/check-utils-changesets.mjs "origin/$BASE_REF" + test: strategy: fail-fast: false diff --git a/AGENTS.md b/AGENTS.md index 3867434..ed3b940 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ This repository hosts codemods that upgrade webpack configurations and APIs, pub Logic reused across codemods lives in the `packages/codemod-utils/` workspace (`@webpack/codemod-utils`), split into `ast.ts` (generic ast-grep helpers: `findPair`, `namedChildren`, `unwrapFilterCall`, …), `imports.ts` (structural `require`/`import` binding detection: `collectModuleBindings`, `requireCallSource`, `unwrapParens`), and `index.ts` (webpack-config helpers — `loaderNameOf`, `findConfigObjectFor`, `ruleMatchesFiles` — plus the `ConfigEditor` class: grouped removals, brace-aware insertion, EOL-aware output, unused-import cleanup). Import it from a codemod by adding `"@webpack/codemod-utils": "*"` to its `dependencies`; the jssg runner bundles it. Prefer extending it over copying helpers between codemods. -The utils package is internal: it is never published and stays at version `0.0.0`. When a change to it affects released codemods, add a changeset for **each affected codemod** (they bundle the utils, so they are what needs re-publishing). +The utils package is internal: it is never published and stays at version `0.0.0`. When a change to it affects released codemods, add a changeset for **each affected codemod** (they bundle the utils, so they are what needs re-publishing) — CI fails the PR if utils sources change without one. ## Creating a codemod diff --git a/eslint.config.mjs b/eslint.config.mjs index 567ace9..a8826d4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,4 +12,13 @@ export default tseslint.config( reportUnusedDisableDirectives: "error", }, }, + { + files: [".github/scripts/**/*.mjs"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + }, + }, + }, ); From a150949ef14b816aa72b74a0224372a5e75fb93c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:02:08 -0500 Subject: [PATCH 28/59] fix: never double carriage returns when generated text embeds source fragments --- .../tests/expected/crlf.config.js | 4 ++++ .../tests/input/crlf.config.js | 9 ++++++++- packages/codemod-utils/src/index.ts | 11 ++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/codemods/css-plugins-to-native-css/tests/expected/crlf.config.js b/codemods/css-plugins-to-native-css/tests/expected/crlf.config.js index 4236804..76e02a2 100644 --- a/codemods/css-plugins-to-native-css/tests/expected/crlf.config.js +++ b/codemods/css-plugins-to-native-css/tests/expected/crlf.config.js @@ -7,6 +7,10 @@ module.exports = { { test: /\.css$/, include: "src", + use: [{ + loader: "postcss-loader", + options: { postcssOptions: {} }, + }], type: "css/auto", }, ], diff --git a/codemods/css-plugins-to-native-css/tests/input/crlf.config.js b/codemods/css-plugins-to-native-css/tests/input/crlf.config.js index 65c162f..830871e 100644 --- a/codemods/css-plugins-to-native-css/tests/input/crlf.config.js +++ b/codemods/css-plugins-to-native-css/tests/input/crlf.config.js @@ -4,7 +4,14 @@ module.exports = { { test: /\.css$/, include: "src", - use: ["style-loader", "css-loader"], + use: [ + "style-loader", + "css-loader", + { + loader: "postcss-loader", + options: { postcssOptions: {} }, + }, + ], }, ], }, diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index 7a725b9..cc1e0e0 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -92,9 +92,14 @@ export class ConfigEditor { return this.edits.length > 0; } - // Replacement text may use "\n"; it is converted to the source's EOL. + // Generated text may mix "\n" with source fragments that already carry the + // file's EOL — normalize first so the conversion never doubles a "\r". + private toSourceEol(text: string): string { + return text.split("\r\n").join("\n").split("\n").join(this.eol); + } + replace(node: SgNode, text: string): void { - this.edits.push(node.replace(text.split("\n").join(this.eol))); + this.edits.push(node.replace(this.toSourceEol(text))); this.editedRanges.push(rangeOf(node)); } @@ -153,7 +158,7 @@ export class ConfigEditor { this.edits.push({ startPos: insertAt, endPos: insertAt, - insertedText: insertedText.split("\n").join(this.eol), + insertedText: this.toSourceEol(insertedText), }); } From 89c3d8882a9950446cc5a553464591de0a7cdfb0 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:04:51 -0500 Subject: [PATCH 29/59] fix: tag private packages so the publish job actually fires, harden version regex --- .changeset/config.json | 6 +++++- .github/scripts/sync-codemod-versions.mjs | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 5c58ec9..0fd74b7 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,9 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "privatePackages": { + "version": true, + "tag": true + } } diff --git a/.github/scripts/sync-codemod-versions.mjs b/.github/scripts/sync-codemod-versions.mjs index b86013b..be82ffd 100644 --- a/.github/scripts/sync-codemod-versions.mjs +++ b/.github/scripts/sync-codemod-versions.mjs @@ -11,7 +11,10 @@ for (const entry of readdirSync(codemodsDir, { withFileTypes: true })) { if (!existsSync(packagePath) || !existsSync(manifestPath)) continue; const { version } = JSON.parse(readFileSync(packagePath, "utf8")); const manifest = readFileSync(manifestPath, "utf8"); - const updated = manifest.replace(/^version: .*$/m, `version: "${version}"`); + const updated = manifest.replace( + /^version:\s*(['"]?)([^'"\n]+)\1\s*$/m, + `version: "${version}"`, + ); if (updated !== manifest) { writeFileSync(manifestPath, updated); console.log(`${manifestPath} -> ${version}`); From 70282f063e45beb0ae09bd919a3145f4bba767ee Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:16:47 -0500 Subject: [PATCH 30/59] refactor: delegate import resolution to @jssg/utils --- AGENTS.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 6 +- .../expected/parenthesized-require.config.js | 5 -- .../input/parenthesized-require.config.js | 13 ---- package-lock.json | 9 +++ packages/codemod-utils/package.json | 3 + packages/codemod-utils/src/imports.ts | 71 +++++++------------ tsconfig.json | 4 +- 8 files changed, 45 insertions(+), 68 deletions(-) delete mode 100644 codemods/css-plugins-to-native-css/tests/expected/parenthesized-require.config.js delete mode 100644 codemods/css-plugins-to-native-css/tests/input/parenthesized-require.config.js diff --git a/AGENTS.md b/AGENTS.md index ed3b940..1d55ba8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ This repository hosts codemods that upgrade webpack configurations and APIs, pub ## Shared utilities -Logic reused across codemods lives in the `packages/codemod-utils/` workspace (`@webpack/codemod-utils`), split into `ast.ts` (generic ast-grep helpers: `findPair`, `namedChildren`, `unwrapFilterCall`, …), `imports.ts` (structural `require`/`import` binding detection: `collectModuleBindings`, `requireCallSource`, `unwrapParens`), and `index.ts` (webpack-config helpers — `loaderNameOf`, `findConfigObjectFor`, `ruleMatchesFiles` — plus the `ConfigEditor` class: grouped removals, brace-aware insertion, EOL-aware output, unused-import cleanup). Import it from a codemod by adding `"@webpack/codemod-utils": "*"` to its `dependencies`; the jssg runner bundles it. Prefer extending it over copying helpers between codemods. +Logic reused across codemods lives in the `packages/codemod-utils/` workspace (`@webpack/codemod-utils`), split into `ast.ts` (generic ast-grep helpers: `findPair`, `namedChildren`, `unwrapFilterCall`, …), `imports.ts` (`collectModuleBindings`, a thin wrapper over the official [`@jssg/utils`](https://github.com/codemod/codemod/tree/main/packages/jssg-utils) import resolution), and `index.ts` (webpack-config helpers — `loaderNameOf`, `findConfigObjectFor`, `ruleMatchesFiles` — plus the `ConfigEditor` class: grouped removals, brace-aware insertion, EOL-aware output, unused-import cleanup). Prefer `@jssg/utils` primitives over hand-rolled AST matching when they cover the case. Import it from a codemod by adding `"@webpack/codemod-utils": "*"` to its `dependencies`; the jssg runner bundles it. Prefer extending it over copying helpers between codemods. The utils package is internal: it is never published and stays at version `0.0.0`. When a change to it affects released codemods, add a changeset for **each affected codemod** (they bundle the utils, so they are what needs re-publishing) — CI fails the PR if utils sources change without one. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 9219fe8..f77183f 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -12,7 +12,6 @@ import { loaderNameOf, namedChildren, pairsOf, - requireCallSource, ruleMatchesFiles, unquote, unwrapFilterCall, @@ -103,7 +102,10 @@ class CssMigration { } if (!objectPart) return false; if (objectPart.kind() === "identifier") return this.pluginNames.has(objectPart.text()); - return requireCallSource(objectPart) === PLUGIN_MODULE; + return ( + objectPart.kind() === "call_expression" && + /^require\(\s*["'`]mini-css-extract-plugin["'`]\s*\)$/.test(objectPart.text()) + ); } // A `use` entry replaceable by native CSS, unwrapping dev/prod guards. diff --git a/codemods/css-plugins-to-native-css/tests/expected/parenthesized-require.config.js b/codemods/css-plugins-to-native-css/tests/expected/parenthesized-require.config.js deleted file mode 100644 index 4f4a82f..0000000 --- a/codemods/css-plugins-to-native-css/tests/expected/parenthesized-require.config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - output: { - cssFilename: "[name].css", - }, -}; diff --git a/codemods/css-plugins-to-native-css/tests/input/parenthesized-require.config.js b/codemods/css-plugins-to-native-css/tests/input/parenthesized-require.config.js deleted file mode 100644 index 7f9902f..0000000 --- a/codemods/css-plugins-to-native-css/tests/input/parenthesized-require.config.js +++ /dev/null @@ -1,13 +0,0 @@ -const MiniCssExtractPlugin = (require)("mini-css-extract-plugin"); - -module.exports = { - module: { - rules: [ - { - test: /\.css$/, - use: [(require)("mini-css-extract-plugin").loader, "css-loader"], - }, - ], - }, - plugins: [new MiniCssExtractPlugin({ filename: "[name].css" })], -}; diff --git a/package-lock.json b/package-lock.json index a6d176c..3f0ce0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -543,6 +543,12 @@ } } }, + "node_modules/@jssg/utils": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@jssg/utils/-/utils-0.0.9.tgz", + "integrity": "sha512-dQDR5At7VEXfEuiwl7pIehm7MyIGJpDn4C0I015xMDHWeceJuWMMjmBcrDVfi75CLgfB90CSLi1tP1HErduznQ==", + "license": "Apache-2.0" + }, "node_modules/@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", @@ -2659,6 +2665,9 @@ "name": "@webpack/codemod-utils", "version": "0.0.0", "license": "MIT", + "dependencies": { + "@jssg/utils": "0.0.9" + }, "devDependencies": { "@codemod.com/jssg-types": "^1.6.2" } diff --git a/packages/codemod-utils/package.json b/packages/codemod-utils/package.json index e042081..23c77b7 100644 --- a/packages/codemod-utils/package.json +++ b/packages/codemod-utils/package.json @@ -18,6 +18,9 @@ }, "author": "Sebastian Beltran ", "license": "MIT", + "dependencies": { + "@jssg/utils": "0.0.9" + }, "devDependencies": { "@codemod.com/jssg-types": "^1.6.2" } diff --git a/packages/codemod-utils/src/imports.ts b/packages/codemod-utils/src/imports.ts index b7d8a32..c8f1547 100644 --- a/packages/codemod-utils/src/imports.ts +++ b/packages/codemod-utils/src/imports.ts @@ -1,7 +1,8 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode } from "@codemod.com/jssg-types/main"; +import { getAllImports } from "@jssg/utils/javascript/imports"; -import { namedChildren, unquote } from "./ast"; +import { namedChildren } from "./ast"; // A top-level `require`/`import` binding of a given module. export interface ModuleBinding { @@ -9,55 +10,35 @@ export interface ModuleBinding { statement: SgNode; } -export function unwrapParens(node: SgNode): SgNode { - let current = node; - while (current.kind() === "parenthesized_expression") { - const inner = namedChildren(current)[0]; - if (!inner) return current; - current = inner; +// The whole removable statement behind a binding identifier: its import +// statement, or its variable statement when it declares nothing else. +function bindingStatementOf(identifier: SgNode): SgNode | null { + let current = identifier.parent(); + while (current) { + const kind = current.kind(); + if (kind === "import_statement") return current; + if (kind === "lexical_declaration" || kind === "variable_declaration") { + const declarators = namedChildren(current).filter( + (child) => child.kind() === "variable_declarator", + ); + return declarators.length === 1 ? current : null; + } + current = current.parent(); } - return current; + return null; } -// The module name of a `require("...")` call — parenthesized forms like -// `(require)("...")` included — or null when the node is anything else. -export function requireCallSource(node: SgNode): string | null { - const call = unwrapParens(node); - if (call.kind() !== "call_expression") return null; - const callee = call.field("function"); - if (!callee || unwrapParens(callee).text() !== "require") return null; - const argumentsNode = call.field("arguments"); - const args = argumentsNode ? namedChildren(argumentsNode) : []; - if (args.length !== 1 || args[0].kind() !== "string") return null; - return unquote(args[0].text()); -} - -// Top-level `require`/`import` bindings of the given module, matched -// structurally so aliased/parenthesized forms are covered too. +// Top-level `require`/`import` bindings of the given module, resolved by +// @jssg/utils (ESM, CJS, aliases, namespace, dynamic import). export function collectModuleBindings(rootNode: SgNode, moduleName: string): ModuleBinding[] { const bindings: ModuleBinding[] = []; - for (const statement of rootNode.findAll({ rule: { kind: "import_statement" } })) { - const source = statement.field("source"); - if (!source || unquote(source.text()) !== moduleName) continue; - const clause = statement.children().find((child) => child.kind() === "import_clause"); - const defaultImport = clause - ? namedChildren(clause).find((child) => child.kind() === "identifier") - : undefined; - if (defaultImport) bindings.push({ name: defaultImport.text(), statement }); - } - for (const declarator of rootNode.findAll({ rule: { kind: "variable_declarator" } })) { - const name = declarator.field("name"); - const value = declarator.field("value"); - if (!name || name.kind() !== "identifier" || !value) continue; - if (requireCallSource(value) !== moduleName) continue; - const statement = declarator.parent(); - if (!statement) continue; - // Multi-declarator statements can't be removed wholesale — leave them alone. - const declaratorCount = namedChildren(statement).filter( - (child) => child.kind() === "variable_declarator", - ).length; - if (declaratorCount !== 1) continue; - bindings.push({ name: name.text(), statement }); + const seen = new Set(); + const program = rootNode as SgNode; + for (const resolved of getAllImports(program, { type: "default", from: moduleName })) { + const statement = bindingStatementOf(resolved.node); + if (!statement || seen.has(resolved.alias)) continue; + seen.add(resolved.alias); + bindings.push({ name: resolved.alias, statement }); } return bindings; } diff --git a/tsconfig.json b/tsconfig.json index c1d3cb5..428000f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": ["ES2023"], "strict": true, "noEmit": true, "skipLibCheck": true, From 8568094bfa9c33030abce2ff5d6097629891d181 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:18:48 -0500 Subject: [PATCH 31/59] refactor: structural check for inline plugin require instead of regex --- .../css-plugins-to-native-css/src/workflow.ts | 15 +++++++++++---- .../tests/expected/inline-require.config.js | 1 + .../tests/input/inline-require.config.js | 10 ++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/expected/inline-require.config.js create mode 100644 codemods/css-plugins-to-native-css/tests/input/inline-require.config.js diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index f77183f..55c8853 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -102,10 +102,17 @@ class CssMigration { } if (!objectPart) return false; if (objectPart.kind() === "identifier") return this.pluginNames.has(objectPart.text()); - return ( - objectPart.kind() === "call_expression" && - /^require\(\s*["'`]mini-css-extract-plugin["'`]\s*\)$/.test(objectPart.text()) - ); + return this.isInlinePluginRequire(objectPart); + } + + // An inline `require("mini-css-extract-plugin")` call expression. + private isInlinePluginRequire(node: SgNode): boolean { + if (node.kind() !== "call_expression") return false; + const callee = node.field("function"); + if (!callee || callee.kind() !== "identifier" || callee.text() !== "require") return false; + const argumentsNode = node.field("arguments"); + const args = argumentsNode ? namedChildren(argumentsNode) : []; + return args.length === 1 && args[0].kind() === "string" && unquote(args[0].text()) === PLUGIN_MODULE; } // A `use` entry replaceable by native CSS, unwrapping dev/prod guards. diff --git a/codemods/css-plugins-to-native-css/tests/expected/inline-require.config.js b/codemods/css-plugins-to-native-css/tests/expected/inline-require.config.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/expected/inline-require.config.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/codemods/css-plugins-to-native-css/tests/input/inline-require.config.js b/codemods/css-plugins-to-native-css/tests/input/inline-require.config.js new file mode 100644 index 0000000..d0b5d86 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/input/inline-require.config.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [require("mini-css-extract-plugin").loader, "css-loader"], + }, + ], + }, +}; From cd0720d4c327243af7c7c3ea389742f91c017cc3 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:23:43 -0500 Subject: [PATCH 32/59] refactor: adopt per-case test directory layout --- .gitattributes | 2 +- AGENTS.md | 7 +++---- CONTRIBUTING.md | 5 ++--- codemods/css-plugins-to-native-css/package.json | 2 +- .../{expected/webpack.config.js => basic/expected.js} | 0 .../tests/{input/webpack.config.js => basic/input.js} | 0 .../expected.js} | 0 .../computed-loader.config.js => computed-loader/input.js} | 0 .../cra-ejected.config.js => cra-ejected/expected.js} | 0 .../{input/cra-ejected.config.js => cra-ejected/input.js} | 0 .../tests/{expected/crlf.config.js => crlf/expected.js} | 0 .../tests/{input/crlf.config.js => crlf/input.js} | 0 .../expected.js} | 0 .../input.js} | 0 .../css-modules.config.js => css-modules/expected.js} | 0 .../{input/css-modules.config.js => css-modules/input.js} | 0 .../{expected/css-only.config.js => css-only/expected.js} | 0 .../tests/{input/css-only.config.js => css-only/input.js} | 0 .../expected.js} | 0 .../css-url-option.config.js => css-url-option/input.js} | 0 .../custom-filter.config.js => custom-filter/expected.js} | 0 .../custom-filter.config.js => custom-filter/input.js} | 0 .../{expected/webpack.config.mjs => esm/expected.mjs} | 0 .../tests/{input/webpack.config.mjs => esm/input.mjs} | 0 .../expected.js} | 0 .../function-config.config.js => function-config/input.js} | 0 .../expected.js} | 0 .../inline-require.config.js => inline-require/input.js} | 0 .../keep-rule.config.js => keep-rule/expected.js} | 0 .../{input/keep-rule.config.js => keep-rule/input.js} | 0 .../{expected/legacy.config.js => legacy/expected.js} | 0 .../tests/{input/legacy.config.js => legacy/input.js} | 0 .../{expected/no-css.config.js => no-css/expected.js} | 0 .../tests/{input/no-css.config.js => no-css/input.js} | 0 .../one-of-only.config.js => one-of-only/expected.js} | 0 .../{input/one-of-only.config.js => one-of-only/input.js} | 0 .../{expected/one-of.config.js => one-of/expected.js} | 0 .../tests/{input/one-of.config.js => one-of/input.js} | 0 .../tests/{expected/sass.config.js => sass/expected.js} | 0 .../tests/{input/sass.config.js => sass/input.js} | 0 .../expected.js} | 0 .../storybook-main.config.js => storybook-main/input.js} | 0 .../expected.js} | 0 .../unknown-loader.config.js => unknown-loader/input.js} | 0 .../webpack-merge.config.js => webpack-merge/expected.js} | 0 .../webpack-merge.config.js => webpack-merge/input.js} | 0 46 files changed, 7 insertions(+), 9 deletions(-) rename codemods/css-plugins-to-native-css/tests/{expected/webpack.config.js => basic/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/webpack.config.js => basic/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/computed-loader.config.js => computed-loader/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/computed-loader.config.js => computed-loader/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/cra-ejected.config.js => cra-ejected/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/cra-ejected.config.js => cra-ejected/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/crlf.config.js => crlf/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/crlf.config.js => crlf/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/css-modules-only.config.js => css-modules-only/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/css-modules-only.config.js => css-modules-only/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/css-modules.config.js => css-modules/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/css-modules.config.js => css-modules/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/css-only.config.js => css-only/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/css-only.config.js => css-only/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/css-url-option.config.js => css-url-option/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/css-url-option.config.js => css-url-option/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/custom-filter.config.js => custom-filter/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/custom-filter.config.js => custom-filter/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/webpack.config.mjs => esm/expected.mjs} (100%) rename codemods/css-plugins-to-native-css/tests/{input/webpack.config.mjs => esm/input.mjs} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/function-config.config.js => function-config/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/function-config.config.js => function-config/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/inline-require.config.js => inline-require/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/inline-require.config.js => inline-require/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/keep-rule.config.js => keep-rule/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/keep-rule.config.js => keep-rule/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/legacy.config.js => legacy/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/legacy.config.js => legacy/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/no-css.config.js => no-css/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/no-css.config.js => no-css/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/one-of-only.config.js => one-of-only/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/one-of-only.config.js => one-of-only/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/one-of.config.js => one-of/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/one-of.config.js => one-of/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/sass.config.js => sass/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/sass.config.js => sass/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/storybook-main.config.js => storybook-main/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/storybook-main.config.js => storybook-main/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/unknown-loader.config.js => unknown-loader/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/unknown-loader.config.js => unknown-loader/input.js} (100%) rename codemods/css-plugins-to-native-css/tests/{expected/webpack-merge.config.js => webpack-merge/expected.js} (100%) rename codemods/css-plugins-to-native-css/tests/{input/webpack-merge.config.js => webpack-merge/input.js} (100%) diff --git a/.gitattributes b/.gitattributes index b2cc86e..f022253 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,4 +2,4 @@ * text=auto eol=lf # CRLF fixtures exercise the editor's EOL detection — keep their bytes as-is. -codemods/*/tests/**/crlf.config.js -text +codemods/*/tests/crlf/* -text diff --git a/AGENTS.md b/AGENTS.md index 1d55ba8..80ff847 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,8 +33,7 @@ codemods// ├── src/ │ └── workflow.ts # The transform, written with jssg (ast-grep) └── tests/ - ├── input/ # Fixtures before the transform - └── expected/ # The same filenames after the transform + └── / # One directory per case: input.js + expected.js ``` ### File templates @@ -111,7 +110,7 @@ Add `semantic_analysis: file` under `js-ast-grep` when the transform needs scope "description": ".", "type": "module", "scripts": { - "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + "test": "npx codemod jssg test -l typescript ./src/workflow.ts" }, "repository": { "type": "git", @@ -166,7 +165,7 @@ References: [jssg docs](https://docs.codemod.com/jssg) and [ast-grep rule refere ### Tests -Every file in `tests/input/` must have a file with the same name in `tests/expected/` containing the post-transform output. Cover at least: a file that is transformed, a file that must remain untouched, and both CJS/ESM variants when relevant. +Each case is a directory under `tests/` holding an `input.` file and an `expected.` file with the post-transform output (`.js`, `.mjs`, … — the extension decides how the input parses). Cover at least: a file that is transformed, a file that must remain untouched, and both CJS/ESM variants when relevant. ### Checklist before opening a PR diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 68210b4..59ed77a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,7 @@ codemods// ├── src/ │ └── workflow.ts # The transform, written with jssg (ast-grep) └── tests/ - ├── input/ # Fixture files before the transform - └── expected/ # The same files after the transform + └── / # One directory per case: input.js + expected.js ``` ## Adding a new codemod @@ -32,7 +31,7 @@ codemods// 1. Create a new directory under `codemods/` with the structure above. Use a short, kebab-case name that describes the migration (see [AGENTS.md](AGENTS.md) for file templates). 2. Update `codemod.yaml`, `workflow.yaml`, `package.json`, and `README.md` with the new name and description. The package name must be scoped as `@webpack/`. 3. Write the transform in `src/workflow.ts`. See the [jssg documentation](https://docs.codemod.com/jssg) and the [ast-grep rule reference](https://ast-grep.github.io/reference/rule.html). -4. Add fixtures: every file in `tests/input/` must have a matching file in `tests/expected/`. +4. Add fixtures: one directory per case under `tests/`, each with an `input.` and an `expected.` file. 5. Add the codemod to the table in the root [README.md](README.md). ## Testing diff --git a/codemods/css-plugins-to-native-css/package.json b/codemods/css-plugins-to-native-css/package.json index 36443bb..5e9373f 100644 --- a/codemods/css-plugins-to-native-css/package.json +++ b/codemods/css-plugins-to-native-css/package.json @@ -5,7 +5,7 @@ "description": "Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css).", "type": "module", "scripts": { - "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + "test": "npx codemod jssg test -l typescript ./src/workflow.ts" }, "repository": { "type": "git", diff --git a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.js b/codemods/css-plugins-to-native-css/tests/basic/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/webpack.config.js rename to codemods/css-plugins-to-native-css/tests/basic/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/webpack.config.js b/codemods/css-plugins-to-native-css/tests/basic/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/webpack.config.js rename to codemods/css-plugins-to-native-css/tests/basic/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/computed-loader.config.js b/codemods/css-plugins-to-native-css/tests/computed-loader/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/computed-loader.config.js rename to codemods/css-plugins-to-native-css/tests/computed-loader/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/computed-loader.config.js b/codemods/css-plugins-to-native-css/tests/computed-loader/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/computed-loader.config.js rename to codemods/css-plugins-to-native-css/tests/computed-loader/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js b/codemods/css-plugins-to-native-css/tests/cra-ejected/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/cra-ejected.config.js rename to codemods/css-plugins-to-native-css/tests/cra-ejected/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/cra-ejected.config.js b/codemods/css-plugins-to-native-css/tests/cra-ejected/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/cra-ejected.config.js rename to codemods/css-plugins-to-native-css/tests/cra-ejected/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/crlf.config.js b/codemods/css-plugins-to-native-css/tests/crlf/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/crlf.config.js rename to codemods/css-plugins-to-native-css/tests/crlf/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/crlf.config.js b/codemods/css-plugins-to-native-css/tests/crlf/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/crlf.config.js rename to codemods/css-plugins-to-native-css/tests/crlf/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-modules-only.config.js b/codemods/css-plugins-to-native-css/tests/css-modules-only/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/css-modules-only.config.js rename to codemods/css-plugins-to-native-css/tests/css-modules-only/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/css-modules-only.config.js b/codemods/css-plugins-to-native-css/tests/css-modules-only/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/css-modules-only.config.js rename to codemods/css-plugins-to-native-css/tests/css-modules-only/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js b/codemods/css-plugins-to-native-css/tests/css-modules/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/css-modules.config.js rename to codemods/css-plugins-to-native-css/tests/css-modules/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/css-modules.config.js b/codemods/css-plugins-to-native-css/tests/css-modules/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/css-modules.config.js rename to codemods/css-plugins-to-native-css/tests/css-modules/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-only.config.js b/codemods/css-plugins-to-native-css/tests/css-only/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/css-only.config.js rename to codemods/css-plugins-to-native-css/tests/css-only/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/css-only.config.js b/codemods/css-plugins-to-native-css/tests/css-only/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/css-only.config.js rename to codemods/css-plugins-to-native-css/tests/css-only/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/css-url-option.config.js rename to codemods/css-plugins-to-native-css/tests/css-url-option/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/css-url-option.config.js b/codemods/css-plugins-to-native-css/tests/css-url-option/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/css-url-option.config.js rename to codemods/css-plugins-to-native-css/tests/css-url-option/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/custom-filter.config.js b/codemods/css-plugins-to-native-css/tests/custom-filter/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/custom-filter.config.js rename to codemods/css-plugins-to-native-css/tests/custom-filter/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/custom-filter.config.js b/codemods/css-plugins-to-native-css/tests/custom-filter/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/custom-filter.config.js rename to codemods/css-plugins-to-native-css/tests/custom-filter/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs b/codemods/css-plugins-to-native-css/tests/esm/expected.mjs similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/webpack.config.mjs rename to codemods/css-plugins-to-native-css/tests/esm/expected.mjs diff --git a/codemods/css-plugins-to-native-css/tests/input/webpack.config.mjs b/codemods/css-plugins-to-native-css/tests/esm/input.mjs similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/webpack.config.mjs rename to codemods/css-plugins-to-native-css/tests/esm/input.mjs diff --git a/codemods/css-plugins-to-native-css/tests/expected/function-config.config.js b/codemods/css-plugins-to-native-css/tests/function-config/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/function-config.config.js rename to codemods/css-plugins-to-native-css/tests/function-config/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/function-config.config.js b/codemods/css-plugins-to-native-css/tests/function-config/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/function-config.config.js rename to codemods/css-plugins-to-native-css/tests/function-config/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/inline-require.config.js b/codemods/css-plugins-to-native-css/tests/inline-require/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/inline-require.config.js rename to codemods/css-plugins-to-native-css/tests/inline-require/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/inline-require.config.js b/codemods/css-plugins-to-native-css/tests/inline-require/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/inline-require.config.js rename to codemods/css-plugins-to-native-css/tests/inline-require/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/keep-rule.config.js b/codemods/css-plugins-to-native-css/tests/keep-rule/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/keep-rule.config.js rename to codemods/css-plugins-to-native-css/tests/keep-rule/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/keep-rule.config.js b/codemods/css-plugins-to-native-css/tests/keep-rule/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/keep-rule.config.js rename to codemods/css-plugins-to-native-css/tests/keep-rule/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/legacy.config.js b/codemods/css-plugins-to-native-css/tests/legacy/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/legacy.config.js rename to codemods/css-plugins-to-native-css/tests/legacy/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/legacy.config.js b/codemods/css-plugins-to-native-css/tests/legacy/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/legacy.config.js rename to codemods/css-plugins-to-native-css/tests/legacy/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/no-css.config.js b/codemods/css-plugins-to-native-css/tests/no-css/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/no-css.config.js rename to codemods/css-plugins-to-native-css/tests/no-css/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/no-css.config.js b/codemods/css-plugins-to-native-css/tests/no-css/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/no-css.config.js rename to codemods/css-plugins-to-native-css/tests/no-css/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/one-of-only.config.js b/codemods/css-plugins-to-native-css/tests/one-of-only/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/one-of-only.config.js rename to codemods/css-plugins-to-native-css/tests/one-of-only/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/one-of-only.config.js b/codemods/css-plugins-to-native-css/tests/one-of-only/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/one-of-only.config.js rename to codemods/css-plugins-to-native-css/tests/one-of-only/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/one-of.config.js b/codemods/css-plugins-to-native-css/tests/one-of/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/one-of.config.js rename to codemods/css-plugins-to-native-css/tests/one-of/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/one-of.config.js b/codemods/css-plugins-to-native-css/tests/one-of/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/one-of.config.js rename to codemods/css-plugins-to-native-css/tests/one-of/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/sass.config.js b/codemods/css-plugins-to-native-css/tests/sass/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/sass.config.js rename to codemods/css-plugins-to-native-css/tests/sass/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/sass.config.js b/codemods/css-plugins-to-native-css/tests/sass/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/sass.config.js rename to codemods/css-plugins-to-native-css/tests/sass/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/storybook-main.config.js b/codemods/css-plugins-to-native-css/tests/storybook-main/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/storybook-main.config.js rename to codemods/css-plugins-to-native-css/tests/storybook-main/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/storybook-main.config.js b/codemods/css-plugins-to-native-css/tests/storybook-main/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/storybook-main.config.js rename to codemods/css-plugins-to-native-css/tests/storybook-main/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/unknown-loader.config.js b/codemods/css-plugins-to-native-css/tests/unknown-loader/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/unknown-loader.config.js rename to codemods/css-plugins-to-native-css/tests/unknown-loader/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/unknown-loader.config.js b/codemods/css-plugins-to-native-css/tests/unknown-loader/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/unknown-loader.config.js rename to codemods/css-plugins-to-native-css/tests/unknown-loader/input.js diff --git a/codemods/css-plugins-to-native-css/tests/expected/webpack-merge.config.js b/codemods/css-plugins-to-native-css/tests/webpack-merge/expected.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/expected/webpack-merge.config.js rename to codemods/css-plugins-to-native-css/tests/webpack-merge/expected.js diff --git a/codemods/css-plugins-to-native-css/tests/input/webpack-merge.config.js b/codemods/css-plugins-to-native-css/tests/webpack-merge/input.js similarity index 100% rename from codemods/css-plugins-to-native-css/tests/input/webpack-merge.config.js rename to codemods/css-plugins-to-native-css/tests/webpack-merge/input.js From 99aa3f89b0d355c0cdab63c3dfeb94fb3f5259f6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:25:49 -0500 Subject: [PATCH 33/59] docs: drop @latest tag from codemod run commands --- AGENTS.md | 2 +- README.md | 2 +- codemods/css-plugins-to-native-css/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 80ff847..adbb33f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # webpack codemods — agent guide -This repository hosts codemods that upgrade webpack configurations and APIs, published to the [Codemod Registry](https://app.codemod.com/registry) and run with `npx codemod@latest run @webpack/`. +This repository hosts codemods that upgrade webpack configurations and APIs, published to the [Codemod Registry](https://app.codemod.com/registry) and run with `npx codemod run @webpack/`. ## Commands diff --git a/README.md b/README.md index cc11826..120eb19 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A collection of codemods to automatically upgrade webpack configurations and API Run a codemod on your project with the [Codemod CLI](https://docs.codemod.com/cli): ```sh -npx codemod@latest run @webpack/ +npx codemod run @webpack/ ``` ## Codemods diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 913e608..8523dad 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -21,7 +21,7 @@ Only rules the file demonstrably owns as webpack config are transformed (a `rule ## Usage ```sh -npx codemod@latest run @webpack/css-plugins-to-native-css +npx codemod run @webpack/css-plugins-to-native-css ``` ## Example From 4e48c3fd16cea37b8e54bb5bb46b538b72261139 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:29:27 -0500 Subject: [PATCH 34/59] refactor: structural matching for require.resolve and regex literals --- packages/codemod-utils/src/index.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index cc1e0e0..3dab397 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -23,8 +23,18 @@ export * from "./imports"; export function loaderNameOf(node: SgNode): string | null { if (node.kind() === "string") return unquote(node.text()); if (node.kind() === "call_expression") { - const resolved = /^require\.resolve\(\s*(["'`][^"'`]+["'`])\s*\)$/.exec(node.text()); - return resolved ? unquote(resolved[1]) : null; + const callee = node.field("function"); + if ( + !callee || + callee.kind() !== "member_expression" || + callee.field("object")?.text() !== "require" || + callee.field("property")?.text() !== "resolve" + ) { + return null; + } + const argumentsNode = node.field("arguments"); + const args = argumentsNode ? namedChildren(argumentsNode) : []; + return args.length === 1 && args[0].kind() === "string" ? unquote(args[0].text()) : null; } if (node.kind() !== "object") return null; const loaderValue = findPair(node, "loader")?.field("value"); @@ -38,10 +48,10 @@ export function ruleMatchesFiles(ruleObject: SgNode, sampleFiles: string[]): const testValue = findPair(ruleObject, "test")?.field("value"); if (!testValue) return true; if (testValue.kind() !== "regex") return true; - const literal = /^\/(.*)\/([a-z]*)$/s.exec(testValue.text()); - if (!literal) return true; + const pattern = testValue.field("pattern"); + if (!pattern) return true; try { - const regex = new RegExp(literal[1], literal[2]); + const regex = new RegExp(pattern.text(), testValue.field("flags")?.text() ?? ""); return sampleFiles.some((file) => regex.test(file)); } catch { return true; From 68eb4938456c2b2deb216a217f2a0dfc0ccec638 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:33:44 -0500 Subject: [PATCH 35/59] refactor: apply simplify review (shared guard unwrapping, single scans, merged planners) --- .github/scripts/check-utils-changesets.mjs | 50 ++-- .../css-plugins-to-native-css/src/workflow.ts | 235 +++++++----------- packages/codemod-utils/src/ast.ts | 37 +++ packages/codemod-utils/src/index.ts | 10 +- 4 files changed, 164 insertions(+), 168 deletions(-) diff --git a/.github/scripts/check-utils-changesets.mjs b/.github/scripts/check-utils-changesets.mjs index 108c7d5..3abca6a 100644 --- a/.github/scripts/check-utils-changesets.mjs +++ b/.github/scripts/check-utils-changesets.mjs @@ -1,42 +1,52 @@ -// The utils package is internal and never published: when its source changes, -// the codemods that bundle it must be re-released. Fail the PR unless at least -// one dependent codemod is covered by a pending changeset. +// Internal packages under packages/ are never published: when their source +// changes, the codemods that bundle them must be re-released. Fail the PR +// unless at least one dependent codemod is covered by a pending changeset. import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; +function readPackageName(dir, entry) { + const packagePath = join(dir, entry, "package.json"); + if (!existsSync(packagePath)) return null; + return JSON.parse(readFileSync(packagePath, "utf8")); +} + const base = process.argv[2] ?? "origin/main"; const changed = execFileSync("git", ["diff", "--name-only", base, "HEAD"], { encoding: "utf8" }) .split("\n") .filter(Boolean); -if (!changed.some((file) => file.startsWith("packages/codemod-utils/src/"))) { - process.exit(0); +const touchedInternal = []; +for (const entry of readdirSync("packages", { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const pkg = readPackageName("packages", entry.name); + if (!pkg) continue; + if (changed.some((file) => file.startsWith(`packages/${entry.name}/src/`))) { + touchedInternal.push(pkg.name); + } } +if (!touchedInternal.length) process.exit(0); const dependents = []; for (const entry of readdirSync("codemods", { withFileTypes: true })) { if (!entry.isDirectory()) continue; - const packagePath = join("codemods", entry.name, "package.json"); - if (!existsSync(packagePath)) continue; - const pkg = JSON.parse(readFileSync(packagePath, "utf8")); - if (pkg.dependencies?.["@webpack/codemod-utils"]) dependents.push(pkg.name); + const pkg = readPackageName("codemods", entry.name); + if (!pkg) continue; + if (touchedInternal.some((name) => pkg.dependencies?.[name])) dependents.push(pkg.name); } if (!dependents.length) process.exit(0); -const covered = new Set(); -for (const file of readdirSync(".changeset")) { - if (!file.endsWith(".md") || file === "README.md") continue; - const content = readFileSync(join(".changeset", file), "utf8"); - for (const name of dependents) { - if (content.includes(`"${name}"`)) covered.add(name); - } -} -if (covered.size) process.exit(0); +const covered = readdirSync(".changeset") + .filter((file) => file.endsWith(".md") && file !== "README.md") + .some((file) => { + const content = readFileSync(join(".changeset", file), "utf8"); + return dependents.some((name) => content.includes(`"${name}"`)); + }); +if (covered) process.exit(0); console.error( - "packages/codemod-utils changed, but no changeset bumps a dependent codemod.\n" + - "The utils are bundled into the published codemods, so add a changeset for the affected ones:\n" + + `${touchedInternal.join(", ")} changed, but no changeset bumps a dependent codemod.\n` + + "Internal packages are bundled into the published codemods, so add a changeset for the affected ones:\n" + dependents.map((name) => ` - ${name}`).join("\n"), ); process.exit(1); diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 55c8853..696043e 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -3,10 +3,12 @@ import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; import { ConfigEditor, type ModuleBinding, + cascadeRemovalTarget, collectModuleBindings, filterSuffixOf, findConfigObjectFor, findPair, + guardBranchesOf, keyName, lineIndent, loaderNameOf, @@ -19,12 +21,14 @@ import { const PLUGIN_MODULE = "mini-css-extract-plugin"; const REMOVABLE_LOADERS = new Set(["style-loader", "css-loader"]); +const EXTRACT_LOADER_NAME = "MiniCssExtractPlugin.loader"; // Plugin options with a native counterpart; the rest have none and are dropped. const PLUGIN_OPTION_TO_OUTPUT = new Map([ ["filename", "cssFilename"], ["chunkFilename", "cssChunkFilename"], ]); const CSS_SAMPLE_FILES = ["/file.css", "/file.module.css"]; +const PLAIN_CSS_SAMPLE = ["/file.css"]; // Options native CSS covers on its own; any other option is flagged when dropped. const DROPPABLE_CSS_LOADER_OPTIONS = new Set(["importLoaders", "sourceMap", "esModule"]); const DROPPABLE_INJECTION_LOADER_OPTIONS = new Set(["esModule"]); @@ -50,9 +54,9 @@ interface RulesArrayWork { swaps: UseSwap[]; } -interface InsertAction { - target: SgNode; - buildProperties: (indent: string, indentUnit: string) => string[]; +interface PluginRemoval { + element: SgNode; + instantiation: SgNode; } class CssMigration { @@ -61,7 +65,6 @@ class CssMigration { private readonly pluginNames: Set; private readonly configPlans = new Map(); - private readonly insertActions: InsertAction[] = []; constructor(root: SgRoot) { this.editor = new ConfigEditor(root.root()); @@ -70,17 +73,21 @@ class CssMigration { } run(): string | null { - this.transformRules(); - this.transformPlugins(); + const usePairs: SgNode[] = []; + const pluginsPairs: SgNode[] = []; + for (const pair of this.editor.rootNode.findAll({ rule: { kind: "pair" } })) { + const name = keyName(pair); + if (name === "use") usePairs.push(pair); + else if (name === "plugins") pluginsPairs.push(pair); + } + this.transformRules(usePairs); + this.transformPlugins(pluginsPairs); this.planConfigInsertions(); this.editor.finalizeRemovals(); if (!this.editor.hasEdits) return null; for (const binding of this.pluginBindings) { this.editor.removeBindingIfUnused(binding); } - for (const action of this.insertActions) { - this.editor.insertIntoObject(action.target, action.buildProperties); - } return this.editor.commit(); } @@ -112,29 +119,24 @@ class CssMigration { if (!callee || callee.kind() !== "identifier" || callee.text() !== "require") return false; const argumentsNode = node.field("arguments"); const args = argumentsNode ? namedChildren(argumentsNode) : []; - return args.length === 1 && args[0].kind() === "string" && unquote(args[0].text()) === PLUGIN_MODULE; + return ( + args.length === 1 && args[0].kind() === "string" && unquote(args[0].text()) === PLUGIN_MODULE + ); } // A `use` entry replaceable by native CSS, unwrapping dev/prod guards. private isRemovableUseElement(node: SgNode): boolean { - if (this.isPluginLoaderExpression(node)) return true; - if (node.kind() === "binary_expression" && node.field("operator")?.text() === "&&") { - const right = node.field("right"); - return right !== null && this.isRemovableUseElement(right); - } - if (node.kind() === "ternary_expression") { - const consequence = node.field("consequence"); - const alternative = node.field("alternative"); - return ( - consequence !== null && - alternative !== null && - this.isRemovableUseElement(consequence) && - this.isRemovableUseElement(alternative) - ); + const branches = guardBranchesOf(node); + if (branches) { + return branches.length > 0 && branches.every((branch) => this.isRemovableUseElement(branch)); } + if (this.isPluginLoaderExpression(node)) return true; if (node.kind() === "object") { const loaderValue = findPair(node, "loader")?.field("value"); - if (loaderValue && this.isPluginLoaderExpression(loaderValue)) return true; + if (!loaderValue) return false; + if (this.isPluginLoaderExpression(loaderValue)) return true; + const name = loaderNameOf(loaderValue); + return name !== null && REMOVABLE_LOADERS.has(name); } const name = loaderNameOf(node); return name !== null && REMOVABLE_LOADERS.has(name); @@ -143,26 +145,16 @@ class CssMigration { // Dropped options native CSS cannot replicate, qualified per loader; // `modules` counts only when the rule also matches plain `.css` files. private lostLoaderOptions(node: SgNode, ruleObject: SgNode): string[] { - if (node.kind() === "binary_expression") { - const right = node.field("right"); - return right ? this.lostLoaderOptions(right, ruleObject) : []; - } - if (node.kind() === "ternary_expression") { - const consequence = node.field("consequence"); - const alternative = node.field("alternative"); - return [ - ...(consequence ? this.lostLoaderOptions(consequence, ruleObject) : []), - ...(alternative ? this.lostLoaderOptions(alternative, ruleObject) : []), - ]; + const branches = guardBranchesOf(node); + if (branches) { + return branches.flatMap((branch) => this.lostLoaderOptions(branch, ruleObject)); } if (node.kind() !== "object") return []; const loaderValue = findPair(node, "loader")?.field("value"); const isExtractLoader = Boolean(loaderValue && this.isPluginLoaderExpression(loaderValue)); - const loaderName = isExtractLoader ? "MiniCssExtractPlugin.loader" : loaderNameOf(node); + const loaderName = isExtractLoader ? EXTRACT_LOADER_NAME : loaderNameOf(node); if (!loaderName) return []; - if (!isExtractLoader && loaderName !== "css-loader" && loaderName !== "style-loader") { - return []; - } + if (!isExtractLoader && !REMOVABLE_LOADERS.has(loaderName)) return []; const droppable = loaderName === "css-loader" ? DROPPABLE_CSS_LOADER_OPTIONS @@ -175,7 +167,7 @@ class CssMigration { for (const optionPair of pairsOf(optionsValue)) { const name = keyName(optionPair); if (loaderName === "css-loader" && name === "modules") { - if (ruleMatchesFiles(ruleObject, ["/file.css"])) lost.push(`${loaderName}.${name}`); + if (ruleMatchesFiles(ruleObject, PLAIN_CSS_SAMPLE)) lost.push(`${loaderName}.${name}`); } else if (name === null || !droppable.has(name)) { lost.push(`${loaderName}.${name ?? "options"}`); } @@ -185,31 +177,29 @@ class CssMigration { // The plugin instantiation behind a plugins element, unwrapping guards. private pluginInstantiationOf(element: SgNode): SgNode | null { - const candidates: (SgNode | null)[] = [element]; - if (element.kind() === "binary_expression" && element.field("operator")?.text() === "&&") { - candidates.push(element.field("right")); - } - if (element.kind() === "ternary_expression") { - candidates.push(element.field("consequence"), element.field("alternative")); - } - for (const candidate of candidates) { - if (!candidate || candidate.kind() !== "new_expression") continue; - const constructorNode = candidate.field("constructor"); - if (constructorNode && this.pluginNames.has(constructorNode.text())) return candidate; + const branches = guardBranchesOf(element); + if (branches) { + for (const branch of branches) { + const found = this.pluginInstantiationOf(branch); + if (found) return found; + } + return null; } - return null; + if (element.kind() !== "new_expression") return null; + const constructorNode = element.field("constructor"); + return constructorNode && this.pluginNames.has(constructorNode.text()) ? element : null; } // ---------- module.rules ---------- // Trivial rules are dropped (the `experiments.css: "auto"` default takes // over); surviving rules get `type: "css/auto"`. - private transformRules(): void { - const rulesWork = this.collectRulesWork(); + private transformRules(usePairs: SgNode[]): void { + const rulesWork = this.collectRulesWork(usePairs); for (const work of rulesWork.values()) { const allElements = namedChildren(work.arrayNode); if (work.removedElements.length === allElements.length && !work.swaps.length) { - this.editor.markForRemoval(this.cascadeRemovalTarget(work.arrayNode)); + this.editor.markForRemoval(cascadeRemovalTarget(work.arrayNode)); continue; } for (const element of work.removedElements) { @@ -221,19 +211,17 @@ class CssMigration { } } - private collectRulesWork(): Map { + private collectRulesWork(usePairs: SgNode[]): Map { const rulesWork = new Map(); - for (const pair of this.editor.rootNode.findAll({ rule: { kind: "pair" } })) { - if (keyName(pair) !== "use") continue; + for (const pair of usePairs) { const originalValue = pair.field("value"); if (!originalValue) continue; const value = unwrapFilterCall(originalValue); const elements = value.kind() === "array" ? namedChildren(value) : [value]; if (!elements.length) continue; - const removable = elements.filter((element) => this.isRemovableUseElement(element)); // Any other loader (preprocessors, custom ones) stays in front of native CSS. const kept = elements.filter((element) => !this.isRemovableUseElement(element)); - if (!removable.length) continue; + if (kept.length === elements.length) continue; const ruleObject = pair.parent(); if (!ruleObject || ruleObject.kind() !== "object") continue; const arrayNode = ruleObject.parent(); @@ -282,28 +270,6 @@ class CssMigration { return findConfigObjectFor(usePair) !== null; } - // Climb while the removal would leave an empty container behind, so a - // css-only `oneOf` → rule → `rules` → `module` chain collapses as one removal. - private cascadeRemovalTarget(node: SgNode): SgNode { - let target = node; - for (;;) { - const parent = target.parent(); - if (!parent) return target; - if (parent.kind() === "pair") { - target = parent; - continue; - } - if (parent.kind() !== "object" && parent.kind() !== "array") return target; - const members = parent.kind() === "object" ? pairsOf(parent) : namedChildren(parent); - if (members.length !== 1) return target; - const grandparent = parent.parent(); - if (!grandparent || (grandparent.kind() !== "pair" && grandparent.kind() !== "array")) { - return target; - } - target = parent; - } - } - private replaceUsePair(swap: UseSwap): void { const indent = lineIndent(this.editor.source, swap.pair.range().start.index); const multiline = swap.ruleObject.text().includes("\n"); @@ -316,10 +282,7 @@ class CssMigration { if (swap.keptLoaders.length) { // Guarded entries keep the original `.filter(...)` for their falsy branch. const keptTexts = swap.keptLoaders.map((loader) => loader.text()); - const keepsGuard = swap.keptLoaders.some( - (loader) => - loader.kind() === "binary_expression" || loader.kind() === "ternary_expression", - ); + const keepsGuard = swap.keptLoaders.some((loader) => guardBranchesOf(loader) !== null); const filterSuffix = keepsGuard ? swap.filterSuffix || ".filter(Boolean)" : ""; const separator = multiline ? `,\n${indent}` : ", "; this.editor.replace( @@ -337,29 +300,33 @@ class CssMigration { // ---------- plugins ---------- - private transformPlugins(): void { - for (const pair of this.editor.rootNode.findAll({ rule: { kind: "pair" } })) { - if (keyName(pair) !== "plugins") continue; - let value = pair.field("value"); - if (value) value = unwrapFilterCall(value); - if (!value || value.kind() !== "array") continue; + private transformPlugins(pluginsPairs: SgNode[]): void { + for (const pair of pluginsPairs) { + const originalValue = pair.field("value"); + if (!originalValue) continue; + const value = unwrapFilterCall(originalValue); + if (value.kind() !== "array") continue; const elements = namedChildren(value); - const removed = elements.filter((element) => this.pluginInstantiationOf(element) !== null); + const removed: PluginRemoval[] = []; + for (const element of elements) { + const instantiation = this.pluginInstantiationOf(element); + if (instantiation) removed.push({ element, instantiation }); + } if (!removed.length) continue; this.collectPluginOptions(pair, removed); if (removed.length === elements.length) { this.editor.markForRemoval(pair); } else { - for (const element of removed) this.editor.markForRemoval(element); + for (const removal of removed) this.editor.markForRemoval(removal.element); } } } - private collectPluginOptions(pluginsPair: SgNode, removed: SgNode[]): void { + private collectPluginOptions(pluginsPair: SgNode, removed: PluginRemoval[]): void { const configObject = pluginsPair.parent(); if (!configObject || configObject.kind() !== "object") return; - for (const element of removed) { - const argumentsNode = this.pluginInstantiationOf(element)?.field("arguments"); + for (const removal of removed) { + const argumentsNode = removal.instantiation.field("arguments"); const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; if (!optionsObject || optionsObject.kind() !== "object") continue; const plan = this.planFor(configObject); @@ -389,61 +356,43 @@ class CssMigration { private planConfigInsertions(): void { for (const plan of this.configPlans.values()) { const topProperties: ((indent: string, unit: string) => string)[] = []; - this.planExperimentsCss(plan, topProperties); - this.planOutputProps(plan, topProperties); + if (plan.needsExperimentsCss) { + this.planObjectProps(plan.config, "experiments", [{ name: "css", valueText: "true" }], topProperties); + } + if (plan.outputProps.length) { + this.planObjectProps(plan.config, "output", plan.outputProps, topProperties); + } if (topProperties.length) { // A fully-emptied config keeps its braces open for these properties. this.editor.keepBracesOpen(plan.config); - this.insertActions.push({ - target: plan.config, - buildProperties: (indent, unit) => topProperties.map((build) => build(indent, unit)), - }); - } - } - } - - private planExperimentsCss( - plan: ConfigPlan, - topProperties: ((indent: string, unit: string) => string)[], - ): void { - if (!plan.needsExperimentsCss) return; - const experimentsValue = findPair(plan.config, "experiments")?.field("value"); - if (experimentsValue && experimentsValue.kind() === "object") { - if (!findPair(experimentsValue, "css")) { - this.insertActions.push({ - target: experimentsValue, - buildProperties: () => ["css: true"], - }); + this.editor.insertIntoObject(plan.config, (indent, unit) => + topProperties.map((build) => build(indent, unit)), + ); } - } else if (!experimentsValue) { - topProperties.push((indent, unit) => - indent || unit - ? `experiments: {\n${indent}${unit}css: true,\n${indent}}` - : "experiments: { css: true }", - ); } } - private planOutputProps( - plan: ConfigPlan, + // Insert props into the config's `key` object, creating it when absent; an + // existing non-object value (e.g. a variable) is left alone. + private planObjectProps( + config: SgNode, + key: string, + props: { name: string; valueText: string }[], topProperties: ((indent: string, unit: string) => string)[], ): void { - if (!plan.outputProps.length) return; - const outputValue = findPair(plan.config, "output")?.field("value"); - const propTexts = plan.outputProps.map((prop) => `${prop.name}: ${prop.valueText}`); - if (outputValue && outputValue.kind() === "object") { - const missing = plan.outputProps - .filter((prop) => !findPair(outputValue, prop.name)) + const value = findPair(config, key)?.field("value"); + if (value && value.kind() === "object") { + const missing = props + .filter((prop) => !findPair(value, prop.name)) .map((prop) => `${prop.name}: ${prop.valueText}`); - if (missing.length) { - this.insertActions.push({ target: outputValue, buildProperties: () => missing }); - } - } else if (!outputValue) { - topProperties.push((indent, unit) => - indent || unit - ? `output: {\n${propTexts.map((text) => `${indent}${unit}${text}`).join(",\n")},\n${indent}}` - : `output: { ${propTexts.join(", ")} }`, - ); + if (missing.length) this.editor.insertIntoObject(value, () => missing); + } else if (!value) { + topProperties.push((indent, unit) => { + const texts = props.map((prop) => `${prop.name}: ${prop.valueText}`); + return indent || unit + ? `${key}: {\n${texts.map((text) => `${indent}${unit}${text}`).join(",\n")},\n${indent}}` + : `${key}: { ${texts.join(", ")} }`; + }); } } } diff --git a/packages/codemod-utils/src/ast.ts b/packages/codemod-utils/src/ast.ts index 26379cd..f081386 100644 --- a/packages/codemod-utils/src/ast.ts +++ b/packages/codemod-utils/src/ast.ts @@ -43,6 +43,43 @@ export function isInsideAny(range: Range, ranges: Range[]): boolean { return ranges.some((outer) => range.start >= outer.start && range.end <= outer.end); } +// Effective branches behind a dev/prod guard (`cond && x`, `cond ? a : b`), +// or null when the node is not a guard. +export function guardBranchesOf(node: SgNode): SgNode[] | null { + if (node.kind() === "binary_expression" && node.field("operator")?.text() === "&&") { + const right = node.field("right"); + return right ? [right] : []; + } + if (node.kind() === "ternary_expression") { + const branches = [node.field("consequence"), node.field("alternative")]; + return branches.filter((branch): branch is SgNode => branch !== null); + } + return null; +} + +// Climb while the removal would leave an empty container behind, so chains +// like a css-only `oneOf` → rule → `rules` → `module` collapse as one removal. +// Stops where a container keeps other members or is not a property/element. +export function cascadeRemovalTarget(node: SgNode): SgNode { + let target = node; + for (;;) { + const parent = target.parent(); + if (!parent) return target; + if (parent.kind() === "pair") { + target = parent; + continue; + } + if (parent.kind() !== "object" && parent.kind() !== "array") return target; + const members = parent.kind() === "object" ? pairsOf(parent) : namedChildren(parent); + if (members.length !== 1) return target; + const grandparent = parent.parent(); + if (!grandparent || (grandparent.kind() !== "pair" && grandparent.kind() !== "array")) { + return target; + } + target = parent; + } +} + // `[ ... ].filter()` — return the inner array literal. export function unwrapFilterCall(node: SgNode): SgNode { if (node.kind() !== "call_expression") return node; diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index 3dab397..27bc7d2 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -88,6 +88,7 @@ export class ConfigEditor { >(); // Fully-emptied objects that must keep their braces open for new properties. private readonly keepOpenTargets = new Set(); + private identifierNodes: SgNode[] | null = null; // Line ending of the source file; generated text must use it too. readonly eol: string; @@ -103,9 +104,9 @@ export class ConfigEditor { } // Generated text may mix "\n" with source fragments that already carry the - // file's EOL — normalize first so the conversion never doubles a "\r". + // file's EOL — normalize both so the conversion never doubles a "\r". private toSourceEol(text: string): string { - return text.split("\r\n").join("\n").split("\n").join(this.eol); + return text.replace(/\r?\n/g, this.eol); } replace(node: SgNode, text: string): void { @@ -176,9 +177,8 @@ export class ConfigEditor { // ranges. Call after finalizeRemovals so those ranges are complete. removeBindingIfUnused(binding: ModuleBinding): void { const statementRange = rangeOf(binding.statement); - const survivingReference = this.rootNode - .findAll({ rule: { kind: "identifier" } }) - .some((identifier) => { + this.identifierNodes ??= this.rootNode.findAll({ rule: { kind: "identifier" } }); + const survivingReference = this.identifierNodes.some((identifier) => { if (identifier.text() !== binding.name) return false; const range = rangeOf(identifier); if (range.start >= statementRange.start && range.end <= statementRange.end) return false; From 823c2c246250604aa3bcf57f1eacfed7b5d4454f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:37:37 -0500 Subject: [PATCH 36/59] feat: recognize import.meta.resolve loader references --- .../tests/import-meta-resolve/expected.mjs | 1 + .../tests/import-meta-resolve/input.mjs | 10 ++++++++++ packages/codemod-utils/src/index.ts | 6 +++--- 3 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/import-meta-resolve/expected.mjs create mode 100644 codemods/css-plugins-to-native-css/tests/import-meta-resolve/input.mjs diff --git a/codemods/css-plugins-to-native-css/tests/import-meta-resolve/expected.mjs b/codemods/css-plugins-to-native-css/tests/import-meta-resolve/expected.mjs new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/import-meta-resolve/expected.mjs @@ -0,0 +1 @@ +export default {}; diff --git a/codemods/css-plugins-to-native-css/tests/import-meta-resolve/input.mjs b/codemods/css-plugins-to-native-css/tests/import-meta-resolve/input.mjs new file mode 100644 index 0000000..8b511df --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/import-meta-resolve/input.mjs @@ -0,0 +1,10 @@ +export default { + module: { + rules: [ + { + test: /\.css$/, + use: [import.meta.resolve("style-loader"), import.meta.resolve("css-loader")], + }, + ], + }, +}; diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index 27bc7d2..0ca116a 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -19,15 +19,15 @@ export * from "./imports"; // ---------- webpack config helpers ---------- // Loader name behind a `use` entry: a plain string, `require.resolve("...")`, -// or `{ loader: }`. +// `import.meta.resolve("...")`, or `{ loader: }`. export function loaderNameOf(node: SgNode): string | null { if (node.kind() === "string") return unquote(node.text()); if (node.kind() === "call_expression") { const callee = node.field("function"); + const receiver = callee?.kind() === "member_expression" ? callee.field("object")?.text() : null; if ( !callee || - callee.kind() !== "member_expression" || - callee.field("object")?.text() !== "require" || + (receiver !== "require" && receiver !== "import.meta") || callee.field("property")?.text() !== "resolve" ) { return null; From f62d2070858e7d2d95fb3ea7edea8cb71df9207a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:41:28 -0500 Subject: [PATCH 37/59] fix: derive indent unit from nesting depth, cover multicompiler configs --- .../tests/multi-compiler/expected.js | 26 ++++++++++++++++ .../tests/multi-compiler/input.js | 31 +++++++++++++++++++ packages/codemod-utils/src/index.ts | 4 ++- 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 codemods/css-plugins-to-native-css/tests/multi-compiler/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/multi-compiler/input.js diff --git a/codemods/css-plugins-to-native-css/tests/multi-compiler/expected.js b/codemods/css-plugins-to-native-css/tests/multi-compiler/expected.js new file mode 100644 index 0000000..c51875d --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/multi-compiler/expected.js @@ -0,0 +1,26 @@ +module.exports = [ + { + output: { + cssFilename: "web/[name].css", + }, + name: "web", + entry: "./src/index.js", + }, + { + experiments: { + css: true, + }, + name: "ssr", + target: "node", + entry: "./src/server.js", + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + }, + ], + }, + }, +]; diff --git a/codemods/css-plugins-to-native-css/tests/multi-compiler/input.js b/codemods/css-plugins-to-native-css/tests/multi-compiler/input.js new file mode 100644 index 0000000..dcdbee0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/multi-compiler/input.js @@ -0,0 +1,31 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = [ + { + name: "web", + entry: "./src/index.js", + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "web/[name].css" })], + }, + { + name: "ssr", + target: "node", + entry: "./src/server.js", + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: ["style-loader", "css-loader"], + }, + ], + }, + }, +]; diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index 0ca116a..b877744 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -157,7 +157,9 @@ export class ConfigEditor { let insertedText: string; if (multiline) { const indent = lineIndent(this.source, properties[0].range().start.index); - const indentUnit = indent.includes("\t") ? "\t" : indent || " "; + // One indentation step: the first property's indent minus the object's own. + const objectIndent = lineIndent(this.source, objectNode.range().start.index); + const indentUnit = indent.slice(objectIndent.length) || (indent.includes("\t") ? "\t" : " "); insertedText = buildProperties(indent, indentUnit) .map((property) => `\n${indent}${property},`) .join(""); From 2d31e020fc2990a7f103ca88ec0f0739918caed4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:45:18 -0500 Subject: [PATCH 38/59] fix: never touch rule arrays assigned into another tool's config parameter --- .../css-plugins-to-native-css/src/workflow.ts | 15 ++++++++++--- .../tests/next-config/expected.js | 22 +++++++++++++++++++ .../tests/next-config/input.js | 22 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/next-config/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/next-config/input.js diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 696043e..9685183 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -261,11 +261,20 @@ class CssMigration { // Webpack owns the rule when its array hangs on a `rules`/`oneOf` pair, the // config has a `module` ancestor, or the file imports the extract plugin. private isWebpackRuleContext(usePair: SgNode, arrayNode: SgNode): boolean { - const listPair = arrayNode.parent(); - if (listPair && listPair.kind() === "pair") { - const name = keyName(listPair); + const owner = arrayNode.parent(); + if (owner && owner.kind() === "pair") { + const name = keyName(owner); if (name === "rules" || name === "oneOf") return true; } + // Assignments into another tool's mutable config parameter + // (`config.module.rules = [...]` in next.config, Storybook, …) are not ours. + if ( + owner && + owner.kind() === "assignment_expression" && + owner.field("left")?.text() !== "module.exports" + ) { + return false; + } if (this.pluginNames.size > 0) return true; return findConfigObjectFor(usePair) !== null; } diff --git a/codemods/css-plugins-to-native-css/tests/next-config/expected.js b/codemods/css-plugins-to-native-css/tests/next-config/expected.js new file mode 100644 index 0000000..1ccfc67 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/next-config/expected.js @@ -0,0 +1,22 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + reactStrictMode: true, + webpack(config, { isServer }) { + if (!isServer) { + config.plugins.push(new MiniCssExtractPlugin({ filename: "static/css/[name].css" })); + config.module.rules.push({ + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }); + config.module.rules = [ + ...config.module.rules, + { + test: /\.scss$/, + use: ["style-loader", "css-loader", "sass-loader"], + }, + ]; + } + return config; + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/next-config/input.js b/codemods/css-plugins-to-native-css/tests/next-config/input.js new file mode 100644 index 0000000..1ccfc67 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/next-config/input.js @@ -0,0 +1,22 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + reactStrictMode: true, + webpack(config, { isServer }) { + if (!isServer) { + config.plugins.push(new MiniCssExtractPlugin({ filename: "static/css/[name].css" })); + config.module.rules.push({ + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }); + config.module.rules = [ + ...config.module.rules, + { + test: /\.scss$/, + use: ["style-loader", "css-loader", "sass-loader"], + }, + ]; + } + return config; + }, +}; From b6cee766a5f59ed9131f33a9418651593fb768cf Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:48:37 -0500 Subject: [PATCH 39/59] fix: accept own-export assignments in the rule gate, empty standalone arrays in place --- .../css-plugins-to-native-css/src/workflow.ts | 25 +++++++++++++------ .../tests/exports-rules/expected.js | 1 + .../tests/exports-rules/input.js | 8 ++++++ 3 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/exports-rules/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/exports-rules/input.js diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 9685183..ceab4cd 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -199,7 +199,14 @@ class CssMigration { for (const work of rulesWork.values()) { const allElements = namedChildren(work.arrayNode); if (work.removedElements.length === allElements.length && !work.swaps.length) { - this.editor.markForRemoval(cascadeRemovalTarget(work.arrayNode)); + const target = cascadeRemovalTarget(work.arrayNode); + // A standalone array (assignment/declarator value) empties in place — + // grouped removal would strip the `= [...]` and leave a bare reference. + if (target === work.arrayNode) { + this.editor.replace(work.arrayNode, "[]"); + } else { + this.editor.markForRemoval(target); + } continue; } for (const element of work.removedElements) { @@ -267,13 +274,15 @@ class CssMigration { if (name === "rules" || name === "oneOf") return true; } // Assignments into another tool's mutable config parameter - // (`config.module.rules = [...]` in next.config, Storybook, …) are not ours. - if ( - owner && - owner.kind() === "assignment_expression" && - owner.field("left")?.text() !== "module.exports" - ) { - return false; + // (`config.module.rules = [...]` in next.config, Storybook, …) are not + // ours; assignments to the file's own exports are. + if (owner && owner.kind() === "assignment_expression") { + const left = owner.field("left")?.text() ?? ""; + const isOwnExport = + left === "module.exports" || + left.startsWith("module.exports.") || + left.startsWith("exports."); + if (!isOwnExport) return false; } if (this.pluginNames.size > 0) return true; return findConfigObjectFor(usePair) !== null; diff --git a/codemods/css-plugins-to-native-css/tests/exports-rules/expected.js b/codemods/css-plugins-to-native-css/tests/exports-rules/expected.js new file mode 100644 index 0000000..a5840d0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/exports-rules/expected.js @@ -0,0 +1 @@ +exports.cssRules = []; diff --git a/codemods/css-plugins-to-native-css/tests/exports-rules/input.js b/codemods/css-plugins-to-native-css/tests/exports-rules/input.js new file mode 100644 index 0000000..5e648e0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/exports-rules/input.js @@ -0,0 +1,8 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +exports.cssRules = [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, +]; From 2ce569f7f0ee60d5482f23b4f7cab1b3fbcb4036 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:49:48 -0500 Subject: [PATCH 40/59] test: cover aliased plugin binding --- .../tests/aliased-import/expected.js | 5 +++++ .../tests/aliased-import/input.js | 13 +++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 codemods/css-plugins-to-native-css/tests/aliased-import/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/aliased-import/input.js diff --git a/codemods/css-plugins-to-native-css/tests/aliased-import/expected.js b/codemods/css-plugins-to-native-css/tests/aliased-import/expected.js new file mode 100644 index 0000000..4f4a82f --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/aliased-import/expected.js @@ -0,0 +1,5 @@ +module.exports = { + output: { + cssFilename: "[name].css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/aliased-import/input.js b/codemods/css-plugins-to-native-css/tests/aliased-import/input.js new file mode 100644 index 0000000..2ea5d6b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/aliased-import/input.js @@ -0,0 +1,13 @@ +const CssExtract = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [CssExtract.loader, "css-loader"], + }, + ], + }, + plugins: [new CssExtract({ filename: "[name].css" })], +}; From 75be66390e4b976764df6b5133f2ba62a5c1ece1 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 21:59:46 -0500 Subject: [PATCH 41/59] feat: migrate getCompilationHooks taps to the native CssLoadingRuntimeModule --- codemods/css-plugins-to-native-css/README.md | 1 + .../css-plugins-to-native-css/src/workflow.ts | 69 +++++++++++++++++++ .../tests/plugin-hooks/expected.js | 15 ++++ .../tests/plugin-hooks/input.js | 23 +++++++ packages/codemod-utils/src/imports.ts | 2 + packages/codemod-utils/src/index.ts | 9 +++ 6 files changed, 119 insertions(+) create mode 100644 codemods/css-plugins-to-native-css/tests/plugin-hooks/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/plugin-hooks/input.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 8523dad..5e0f52b 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -11,6 +11,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Any other loader in the chain (`sass-loader`, `less-loader`, `postcss-loader`, custom ones, …) keeps working in front of native CSS: it stays in `use` while the injection/extraction loaders are dropped, and the rule gets `type: "css/auto"`. - Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. +- Migrates `MiniCssExtractPlugin.getCompilationHooks(...)` taps to the native runtime's `webpack.web.CssLoadingRuntimeModule.getCompilationHooks(...)`: `linkPreload`/`linkPrefetch` keep their name and signature, and `beforeTagInsert` maps to `linkInsert` — note its callback now receives `(source, chunk)` instead of `(source, varNames)`, so review taps that used the second argument. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. - Loader options are checked while migrating: `importLoaders`, `sourceMap`, and `esModule` are dropped silently (native CSS covers them). Semantic options native CSS cannot replicate — css-loader's `url`/`import`/`exportType`, style-loader's `insert`/`attributes`, `MiniCssExtractPlugin.loader`'s `publicPath`, or css-loader's `modules` on a rule that also matches plain `.css` files — are dropped too, but the rule keeps a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. `modules` on a rule scoped to `.module.css` matches the `css/auto` convention and migrates silently. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index ceab4cd..d66bbc1 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -3,6 +3,7 @@ import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; import { ConfigEditor, type ModuleBinding, + addImport, cascadeRemovalTarget, collectModuleBindings, filterSuffixOf, @@ -65,6 +66,8 @@ class CssMigration { private readonly pluginNames: Set; private readonly configPlans = new Map(); + // Binding statements rewritten in place (e.g. into the `web` import). + private readonly repurposedStatements = new Set(); constructor(root: SgRoot) { this.editor = new ConfigEditor(root.root()); @@ -82,10 +85,12 @@ class CssMigration { } this.transformRules(usePairs); this.transformPlugins(pluginsPairs); + this.migrateCompilationHooks(); this.planConfigInsertions(); this.editor.finalizeRemovals(); if (!this.editor.hasEdits) return null; for (const binding of this.pluginBindings) { + if (this.repurposedStatements.has(binding.statement.range().start.index)) continue; this.editor.removeBindingIfUnused(binding); } return this.editor.commit(); @@ -359,6 +364,70 @@ class CssMigration { } } + // ---------- compilation hooks ---------- + + // Retarget `MiniCssExtractPlugin.getCompilationHooks(...)` to the native + // `CssLoadingRuntimeModule` — `linkPreload`/`linkPrefetch` keep their name + // and signature; `beforeTagInsert` maps to `linkInsert`. Only done when this + // file's CSS setup was actually migrated. + private migrateCompilationHooks(): void { + if (!this.editor.hasWork) return; + const rootNode = this.editor.rootNode; + const receivers: SgNode[] = []; + for (const node of rootNode.findAll({ rule: { kind: "member_expression" } })) { + if (node.field("property")?.text() !== "getCompilationHooks") continue; + const objectPart = node.field("object"); + if (!objectPart) continue; + if ( + (objectPart.kind() === "identifier" && this.pluginNames.has(objectPart.text())) || + this.isInlinePluginRequire(objectPart) + ) { + receivers.push(objectPart); + } + } + if (!receivers.length) return; + const nativeReceiver = this.nativeHooksReceiver(); + for (const objectPart of receivers) this.editor.replace(objectPart, nativeReceiver); + // `beforeTagInsert` has no same-name native hook; `linkInsert` replaces it. + for (const property of rootNode.findAll({ rule: { kind: "property_identifier" } })) { + if (property.text() === "beforeTagInsert") this.editor.replace(property, "linkInsert"); + } + for (const shorthand of rootNode.findAll({ + rule: { kind: "shorthand_property_identifier_pattern" }, + })) { + if (shorthand.text() === "beforeTagInsert") { + // Keep the local variable name; only the destructured key changes. + this.editor.replace(shorthand, "linkInsert: beforeTagInsert"); + } + } + } + + // Existing webpack binding, or a `web` import — rewriting the plugin's own + // import statement in place when possible (a separate added import would + // land inside the removed statement's range and be dropped). + private nativeHooksReceiver(): string { + const webpackBindings = collectModuleBindings(this.editor.rootNode, "webpack"); + if (webpackBindings.length) return `${webpackBindings[0].name}.web.CssLoadingRuntimeModule`; + const binding = this.pluginBindings[0]; + if (binding) { + const isEsm = binding.statement.kind() === "import_statement"; + this.editor.replace( + binding.statement, + isEsm ? 'import { web } from "webpack";' : 'const { web } = require("webpack");', + ); + this.repurposedStatements.add(binding.statement.range().start.index); + } else { + const edit = addImport(this.editor.rootNode as SgNode, { + type: "named", + specifiers: [{ name: "web" }], + from: "webpack", + moduleType: "cjs", + }); + if (edit) this.editor.addEdit(edit); + } + return "web.CssLoadingRuntimeModule"; + } + // ---------- config-level insertions ---------- private planFor(config: SgNode): ConfigPlan { diff --git a/codemods/css-plugins-to-native-css/tests/plugin-hooks/expected.js b/codemods/css-plugins-to-native-css/tests/plugin-hooks/expected.js new file mode 100644 index 0000000..269abe9 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/plugin-hooks/expected.js @@ -0,0 +1,15 @@ +const { web } = require("webpack"); + +class IntegrityPlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap("IntegrityPlugin", (compilation) => { + const hooks = web.CssLoadingRuntimeModule.getCompilationHooks(compilation); + hooks.linkInsert.tap("IntegrityPlugin", (source) => source); + hooks.linkPreload.tap("IntegrityPlugin", (source) => source); + }); + } +} + +module.exports = { + plugins: [new IntegrityPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/plugin-hooks/input.js b/codemods/css-plugins-to-native-css/tests/plugin-hooks/input.js new file mode 100644 index 0000000..211f516 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/plugin-hooks/input.js @@ -0,0 +1,23 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +class IntegrityPlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap("IntegrityPlugin", (compilation) => { + const hooks = MiniCssExtractPlugin.getCompilationHooks(compilation); + hooks.beforeTagInsert.tap("IntegrityPlugin", (source) => source); + hooks.linkPreload.tap("IntegrityPlugin", (source) => source); + }); + } +} + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin(), new IntegrityPlugin()], +}; diff --git a/packages/codemod-utils/src/imports.ts b/packages/codemod-utils/src/imports.ts index c8f1547..a602a64 100644 --- a/packages/codemod-utils/src/imports.ts +++ b/packages/codemod-utils/src/imports.ts @@ -4,6 +4,8 @@ import { getAllImports } from "@jssg/utils/javascript/imports"; import { namedChildren } from "./ast"; +export { addImport } from "@jssg/utils/javascript/imports"; + // A top-level `require`/`import` binding of a given module. export interface ModuleBinding { name: string; diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index b877744..254e330 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -103,6 +103,15 @@ export class ConfigEditor { return this.edits.length > 0; } + // Whether any edit or pending removal has been registered so far. + get hasWork(): boolean { + return this.edits.length > 0 || this.pendingRemovals.size > 0; + } + + addEdit(edit: Edit): void { + this.edits.push(edit); + } + // Generated text may mix "\n" with source fragments that already carry the // file's EOL — normalize both so the conversion never doubles a "\r". private toSourceEol(text: string): string { From 3bd3ca7c3c682f1d354a2df8d131bb12a81464a6 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:00:37 -0500 Subject: [PATCH 42/59] test: cover esm getCompilationHooks migration --- .../tests/plugin-hooks-esm/expected.mjs | 14 ++++++++++++ .../tests/plugin-hooks-esm/input.mjs | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/expected.mjs create mode 100644 codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/input.mjs diff --git a/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/expected.mjs b/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/expected.mjs new file mode 100644 index 0000000..05c8523 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/expected.mjs @@ -0,0 +1,14 @@ +import { web } from "webpack"; + +class IntegrityPlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap("IntegrityPlugin", (compilation) => { + const { linkInsert: beforeTagInsert } = web.CssLoadingRuntimeModule.getCompilationHooks(compilation); + beforeTagInsert.tap("IntegrityPlugin", (source) => source); + }); + } +} + +export default { + plugins: [new IntegrityPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/input.mjs b/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/input.mjs new file mode 100644 index 0000000..be548f3 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/input.mjs @@ -0,0 +1,22 @@ +import MiniCssExtractPlugin from "mini-css-extract-plugin"; + +class IntegrityPlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap("IntegrityPlugin", (compilation) => { + const { beforeTagInsert } = MiniCssExtractPlugin.getCompilationHooks(compilation); + beforeTagInsert.tap("IntegrityPlugin", (source) => source); + }); + } +} + +export default { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin(), new IntegrityPlugin()], +}; From 707d7abf1f1fa9ac788c799aff3efdae0c53f609 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:03:28 -0500 Subject: [PATCH 43/59] feat: remove migrated css packages from package.json via a json transform step --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/package.json | 4 ++- .../src/remove-dependencies.ts | 30 +++++++++++++++++++ .../remove-dependencies/removes/expected.json | 9 ++++++ .../remove-dependencies/removes/input.json | 12 ++++++++ .../untouched/expected.json | 6 ++++ .../remove-dependencies/untouched/input.json | 6 ++++ .../css-plugins-to-native-css/workflow.yaml | 9 ++++++ 8 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/src/remove-dependencies.ts create mode 100644 codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/expected.json create mode 100644 codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/input.json create mode 100644 codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/expected.json create mode 100644 codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/input.json diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 5e0f52b..969e4ca 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -86,4 +86,4 @@ module.exports = { }; ``` -Remember to also remove `mini-css-extract-plugin`, `style-loader`, and `css-loader` from your `package.json` if nothing else uses them. +The codemod also removes `mini-css-extract-plugin`, `style-loader`, and `css-loader` from your `package.json` (`dependencies` and `devDependencies`). If other tooling in the repo still uses them (Storybook, test setups, …), reinstall the ones you need. diff --git a/codemods/css-plugins-to-native-css/package.json b/codemods/css-plugins-to-native-css/package.json index 5e9373f..dd18732 100644 --- a/codemods/css-plugins-to-native-css/package.json +++ b/codemods/css-plugins-to-native-css/package.json @@ -5,7 +5,9 @@ "description": "Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css).", "type": "module", "scripts": { - "test": "npx codemod jssg test -l typescript ./src/workflow.ts" + "test": "npm run test:workflow && npm run test:dependencies", + "test:workflow": "npx codemod jssg test -l typescript ./src/workflow.ts", + "test:dependencies": "npx codemod jssg test -l json ./src/remove-dependencies.ts ./tests/remove-dependencies" }, "repository": { "type": "git", diff --git a/codemods/css-plugins-to-native-css/src/remove-dependencies.ts b/codemods/css-plugins-to-native-css/src/remove-dependencies.ts new file mode 100644 index 0000000..095c98b --- /dev/null +++ b/codemods/css-plugins-to-native-css/src/remove-dependencies.ts @@ -0,0 +1,30 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type Json from "@codemod.com/jssg-types/langs/json"; +import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, findPair, keyName, namedChildren, pairsOf } from "@webpack/codemod-utils"; + +// Packages replaced by native CSS; review your lockfile if other tooling +// (Storybook, tests, …) still relies on them. +const REMOVED_PACKAGES = new Set(["mini-css-extract-plugin", "style-loader", "css-loader"]); +const DEPENDENCY_KEYS = ["dependencies", "devDependencies"]; + +async function transform(root: SgRoot): Promise { + // JSON shares the object/pair/string node kinds the editor operates on. + const rootNode = root.root() as unknown as SgNode; + const editor = new ConfigEditor(rootNode); + const manifest = namedChildren(rootNode)[0]; + if (!manifest || manifest.kind() !== "object") return null; + for (const key of DEPENDENCY_KEYS) { + const value = findPair(manifest, key)?.field("value"); + if (!value || value.kind() !== "object") continue; + for (const pair of pairsOf(value)) { + const name = keyName(pair); + if (name && REMOVED_PACKAGES.has(name)) editor.markForRemoval(pair); + } + } + editor.finalizeRemovals(); + if (!editor.hasEdits) return null; + return editor.commit(); +} + +export default transform; diff --git a/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/expected.json b/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/expected.json new file mode 100644 index 0000000..829eb92 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/expected.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + }, + "devDependencies": { + "webpack": "^5.109.0" + } +} diff --git a/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/input.json b/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/input.json new file mode 100644 index 0000000..422c1ca --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/input.json @@ -0,0 +1,12 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0", + "mini-css-extract-plugin": "^2.9.0" + }, + "devDependencies": { + "css-loader": "^7.0.0", + "style-loader": "^4.0.0", + "webpack": "^5.109.0" + } +} diff --git a/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/expected.json b/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/expected.json new file mode 100644 index 0000000..111e5ea --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/expected.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + } +} diff --git a/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/input.json b/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/input.json new file mode 100644 index 0000000..111e5ea --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/input.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + } +} diff --git a/codemods/css-plugins-to-native-css/workflow.yaml b/codemods/css-plugins-to-native-css/workflow.yaml index f920641..c43846e 100644 --- a/codemods/css-plugins-to-native-css/workflow.yaml +++ b/codemods/css-plugins-to-native-css/workflow.yaml @@ -25,3 +25,12 @@ nodes: exclude: - "**/node_modules/**" language: typescript + - name: Remove mini-css-extract-plugin, style-loader, and css-loader from package.json + js-ast-grep: + js_file: src/remove-dependencies.ts + base_path: . + include: + - "**/package.json" + exclude: + - "**/node_modules/**" + language: json From 6fc4a76dcf623f21ab6241a5561281c070580c39 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:10:37 -0500 Subject: [PATCH 44/59] feat: translate css-loader url/import and modules sub-options to native parser/generator --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 174 +++++++++++++++--- .../tests/css-modules-options/expected.js | 16 ++ .../tests/css-modules-options/input.js | 24 +++ .../tests/css-url-option/expected.js | 2 +- 5 files changed, 189 insertions(+), 29 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/css-modules-options/input.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 969e4ca..316ff46 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -13,7 +13,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. - Migrates `MiniCssExtractPlugin.getCompilationHooks(...)` taps to the native runtime's `webpack.web.CssLoadingRuntimeModule.getCompilationHooks(...)`: `linkPreload`/`linkPrefetch` keep their name and signature, and `beforeTagInsert` maps to `linkInsert` — note its callback now receives `(source, chunk)` instead of `(source, varNames)`, so review taps that used the second argument. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. -- Loader options are checked while migrating: `importLoaders`, `sourceMap`, and `esModule` are dropped silently (native CSS covers them). Semantic options native CSS cannot replicate — css-loader's `url`/`import`/`exportType`, style-loader's `insert`/`attributes`, `MiniCssExtractPlugin.loader`'s `publicPath`, or css-loader's `modules` on a rule that also matches plain `.css` files — are dropped too, but the rule keeps a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. `modules` on a rule scoped to `.module.css` matches the `css/auto` convention and migrates silently. +- Loader options are translated while migrating. `importLoaders`, `sourceMap`, and `esModule` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. Options native CSS cannot replicate — `exportType`, `modules.mode`/`getLocalIdent`, style-loader's `insert`/`attributes`, `MiniCssExtractPlugin.loader`'s `publicPath`, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index d66bbc1..3ddc4b6 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -30,6 +30,14 @@ const PLUGIN_OPTION_TO_OUTPUT = new Map([ ]); const CSS_SAMPLE_FILES = ["/file.css", "/file.module.css"]; const PLAIN_CSS_SAMPLE = ["/file.css"]; +// css-loader `exportLocalsConvention` literals → native `exportsConvention`. +const EXPORTS_CONVENTION_MAP = new Map([ + ["asIs", "as-is"], + ["camelCase", "camel-case"], + ["camelCaseOnly", "camel-case-only"], + ["dashes", "dashes"], + ["dashesOnly", "dashes-only"], +]); // Options native CSS covers on its own; any other option is flagged when dropped. const DROPPABLE_CSS_LOADER_OPTIONS = new Set(["importLoaders", "sourceMap", "esModule"]); const DROPPABLE_INJECTION_LOADER_OPTIONS = new Set(["esModule"]); @@ -41,12 +49,26 @@ interface ConfigPlan { outputProps: { name: string; valueText: string }[]; } +interface RuleProp { + name: string; + valueText: string; +} + +// Loader options translated into the surviving rule, plus the ones lost. +interface OptionFindings { + lost: string[]; + generatorProps: RuleProp[]; + parserProps: RuleProp[]; +} + interface UseSwap { pair: SgNode; ruleObject: SgNode; keptLoaders: SgNode[]; filterSuffix: string; lostOptions: string[]; + generatorProps: RuleProp[]; + parserProps: RuleProp[]; } interface RulesArrayWork { @@ -60,6 +82,11 @@ interface PluginRemoval { instantiation: SgNode; } +function dedupeProps(props: RuleProp[]): RuleProp[] { + const seen = new Set(); + return props.filter((prop) => !seen.has(prop.name) && seen.add(prop.name)); +} + class CssMigration { private readonly editor: ConfigEditor; private readonly pluginBindings: ModuleBinding[]; @@ -147,37 +174,111 @@ class CssMigration { return name !== null && REMOVABLE_LOADERS.has(name); } - // Dropped options native CSS cannot replicate, qualified per loader; - // `modules` counts only when the rule also matches plain `.css` files. - private lostLoaderOptions(node: SgNode, ruleObject: SgNode): string[] { + // Translate each loader option into the surviving rule's `generator`/`parser` + // when native CSS has an equivalent; everything else lands in `lost`. + private collectLoaderOptionFindings( + node: SgNode, + ruleObject: SgNode, + findings: OptionFindings, + ): void { const branches = guardBranchesOf(node); if (branches) { - return branches.flatMap((branch) => this.lostLoaderOptions(branch, ruleObject)); + for (const branch of branches) this.collectLoaderOptionFindings(branch, ruleObject, findings); + return; } - if (node.kind() !== "object") return []; + if (node.kind() !== "object") return; const loaderValue = findPair(node, "loader")?.field("value"); const isExtractLoader = Boolean(loaderValue && this.isPluginLoaderExpression(loaderValue)); const loaderName = isExtractLoader ? EXTRACT_LOADER_NAME : loaderNameOf(node); - if (!loaderName) return []; - if (!isExtractLoader && !REMOVABLE_LOADERS.has(loaderName)) return []; + if (!loaderName) return; + if (!isExtractLoader && !REMOVABLE_LOADERS.has(loaderName)) return; const droppable = loaderName === "css-loader" ? DROPPABLE_CSS_LOADER_OPTIONS : DROPPABLE_INJECTION_LOADER_OPTIONS; const optionsPair = findPair(node, "options"); - if (!optionsPair) return []; + if (!optionsPair) return; const optionsValue = optionsPair.field("value"); - if (!optionsValue || optionsValue.kind() !== "object") return [`${loaderName}.options`]; - const lost: string[] = []; + if (!optionsValue || optionsValue.kind() !== "object") { + findings.lost.push(`${loaderName}.options`); + return; + } for (const optionPair of pairsOf(optionsValue)) { const name = keyName(optionPair); - if (loaderName === "css-loader" && name === "modules") { - if (ruleMatchesFiles(ruleObject, PLAIN_CSS_SAMPLE)) lost.push(`${loaderName}.${name}`); - } else if (name === null || !droppable.has(name)) { - lost.push(`${loaderName}.${name ?? "options"}`); + const value = optionPair.field("value"); + if (name !== null && droppable.has(name)) continue; + if (loaderName !== "css-loader" || name === null || !value) { + findings.lost.push(`${loaderName}.${name ?? "options"}`); + continue; + } + if (name === "url" || name === "import") { + // Booleans map to the rule's parser; filter functions have no equivalent. + if (value.kind() === "true" || value.kind() === "false") { + findings.parserProps.push({ name, valueText: value.text() }); + } else { + findings.lost.push(`${loaderName}.${name}`); + } + } else if (name === "modules") { + this.collectCssModulesFindings(value, ruleObject, findings); + } else { + findings.lost.push(`${loaderName}.${name}`); + } + } + } + + // css-loader `modules` applies to every matched file, while `css/auto` only + // treats `*.module.*` names as CSS modules — on a rule that also matches + // plain `.css` the whole option is lost. Otherwise its sub-options map to + // the native generator/parser where an equivalent exists. + private collectCssModulesFindings( + value: SgNode, + ruleObject: SgNode, + findings: OptionFindings, + ): void { + if (ruleMatchesFiles(ruleObject, PLAIN_CSS_SAMPLE)) { + findings.lost.push("css-loader.modules"); + return; + } + if (value.kind() === "true") return; + if (value.kind() !== "object") { + findings.lost.push("css-loader.modules"); + return; + } + for (const subPair of pairsOf(value)) { + const subName = keyName(subPair); + const subValue = subPair.field("value"); + if (!subName || !subValue) { + findings.lost.push("css-loader.modules"); + continue; + } + switch (subName) { + case "auto": + break; + case "localIdentName": + findings.generatorProps.push({ name: "localIdentName", valueText: subValue.text() }); + break; + case "exportOnlyLocals": + findings.generatorProps.push({ name: "exportsOnly", valueText: subValue.text() }); + break; + case "namedExport": + findings.parserProps.push({ name: "namedExports", valueText: subValue.text() }); + break; + case "exportLocalsConvention": { + const mapped = + subValue.kind() === "string" + ? EXPORTS_CONVENTION_MAP.get(unquote(subValue.text())) + : undefined; + if (mapped) { + findings.generatorProps.push({ name: "exportsConvention", valueText: `"${mapped}"` }); + } else { + findings.lost.push("css-loader.modules.exportLocalsConvention"); + } + break; + } + default: + findings.lost.push(`css-loader.modules.${subName}`); } } - return lost; } // The plugin instantiation behind a plugins element, unwrapping guards. @@ -241,9 +342,13 @@ class CssMigration { // fragments pushed into another tool's config (Storybook, craco, …). if (!arrayNode || arrayNode.kind() !== "array") continue; if (!this.isWebpackRuleContext(pair, arrayNode)) continue; - const lostOptions = [ - ...new Set(elements.flatMap((element) => this.lostLoaderOptions(element, ruleObject))), - ]; + const findings: OptionFindings = { lost: [], generatorProps: [], parserProps: [] }; + for (const element of elements) { + this.collectLoaderOptionFindings(element, ruleObject, findings); + } + const lostOptions = [...new Set(findings.lost)]; + const generatorProps = dedupeProps(findings.generatorProps); + const parserProps = dedupeProps(findings.parserProps); const key = arrayNode.range().start.index; let work = rulesWork.get(key); if (!work) { @@ -254,8 +359,15 @@ class CssMigration { const name = keyName(rulePair); return name === "test" || name === "use"; }); - // A rule with lost options stays as a swap so the comment has a home. - if (trivialRule && !kept.length && !lostOptions.length) { + // A rule with lost or translated options stays as a swap so the comment + // and the generator/parser properties have a home. + const survives = + !trivialRule || + kept.length > 0 || + lostOptions.length > 0 || + generatorProps.length > 0 || + parserProps.length > 0; + if (!survives) { work.removedElements.push(ruleObject); } else { work.swaps.push({ @@ -264,6 +376,8 @@ class CssMigration { keptLoaders: kept, filterSuffix: filterSuffixOf(originalValue, value), lostOptions, + generatorProps, + parserProps, }); } } @@ -302,19 +416,25 @@ class CssMigration { const message = `Removed loader options without a native CSS equivalent: ${swap.lostOptions.join(", ")}`; commentPrefix = multiline ? `// ${message}\n${indent}` : `/* ${message} */ `; } + const separator = multiline ? `,\n${indent}` : ", "; + let replacement = `${commentPrefix}`; if (swap.keptLoaders.length) { // Guarded entries keep the original `.filter(...)` for their falsy branch. const keptTexts = swap.keptLoaders.map((loader) => loader.text()); const keepsGuard = swap.keptLoaders.some((loader) => guardBranchesOf(loader) !== null); const filterSuffix = keepsGuard ? swap.filterSuffix || ".filter(Boolean)" : ""; - const separator = multiline ? `,\n${indent}` : ", "; - this.editor.replace( - swap.pair, - `${commentPrefix}use: [${keptTexts.join(", ")}]${filterSuffix}${separator}type: "css/auto"`, - ); - } else { - this.editor.replace(swap.pair, `${commentPrefix}type: "css/auto"`); + replacement += `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}`; + } + replacement += 'type: "css/auto"'; + for (const [key, props] of [ + ["generator", swap.generatorProps], + ["parser", swap.parserProps], + ] as const) { + if (!props.length) continue; + const texts = props.map((prop) => `${prop.name}: ${prop.valueText}`); + replacement += `${separator}${key}: { ${texts.join(", ")} }`; } + this.editor.replace(swap.pair, replacement); // A surviving rule that matches `.css` turns the "auto" default off. if (!ruleMatchesFiles(swap.ruleObject, CSS_SAMPLE_FILES)) return; const config = findConfigObjectFor(swap.pair); diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js b/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js new file mode 100644 index 0000000..9584dfc --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js @@ -0,0 +1,16 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.module\.css$/, + // Removed loader options without a native CSS equivalent: css-loader.modules.mode + type: "css/auto", + generator: { localIdentName: "[name]__[local]___[hash:base64:5]", exportsOnly: false, exportsConvention: "camel-case" }, + parser: { namedExports: true }, + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js b/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js new file mode 100644 index 0000000..53fff89 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js @@ -0,0 +1,24 @@ +module.exports = { + module: { + rules: [ + { + test: /\.module\.css$/, + use: [ + "style-loader", + { + loader: "css-loader", + options: { + modules: { + localIdentName: "[name]__[local]___[hash:base64:5]", + exportOnlyLocals: false, + namedExport: true, + exportLocalsConvention: "camelCase", + mode: "local", + }, + }, + }, + ], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js index 57dba7d..2421c0a 100644 --- a/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js +++ b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js @@ -6,8 +6,8 @@ module.exports = { rules: [ { test: /\.css$/, - // Removed loader options without a native CSS equivalent: css-loader.url, css-loader.import type: "css/auto", + parser: { url: false, import: false }, }, ], }, From b6c55151b1931002e830dfdf8a6e63b5878581ed Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:19:41 -0500 Subject: [PATCH 45/59] feat: translate css-loader sourceMap:false to per-asset-type devtool --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 38 +++++++++++++++++-- .../tests/css-modules-options/input.js | 1 + .../tests/css-url-option/expected.js | 1 + .../tests/css-url-option/input.js | 3 +- 5 files changed, 39 insertions(+), 6 deletions(-) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 316ff46..a3d4fbd 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -13,7 +13,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. - Migrates `MiniCssExtractPlugin.getCompilationHooks(...)` taps to the native runtime's `webpack.web.CssLoadingRuntimeModule.getCompilationHooks(...)`: `linkPreload`/`linkPrefetch` keep their name and signature, and `beforeTagInsert` maps to `linkInsert` — note its callback now receives `(source, chunk)` instead of `(source, varNames)`, so review taps that used the second argument. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. -- Loader options are translated while migrating. `importLoaders`, `sourceMap`, and `esModule` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. Options native CSS cannot replicate — `exportType`, `modules.mode`/`getLocalIdent`, style-loader's `insert`/`attributes`, `MiniCssExtractPlugin.loader`'s `publicPath`, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. +- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns a string `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. Options native CSS cannot replicate — `exportType`, `modules.mode`/`getLocalIdent`, style-loader's `insert`/`attributes`, `MiniCssExtractPlugin.loader`'s `publicPath`, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 3ddc4b6..fe50f8c 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -39,13 +39,14 @@ const EXPORTS_CONVENTION_MAP = new Map([ ["dashesOnly", "dashes-only"], ]); // Options native CSS covers on its own; any other option is flagged when dropped. -const DROPPABLE_CSS_LOADER_OPTIONS = new Set(["importLoaders", "sourceMap", "esModule"]); +const DROPPABLE_CSS_LOADER_OPTIONS = new Set(["importLoaders", "esModule"]); const DROPPABLE_INJECTION_LOADER_OPTIONS = new Set(["esModule"]); // Properties to add to one webpack config object once all removals are known. interface ConfigPlan { config: SgNode; needsExperimentsCss: boolean; + disableCssSourceMap: boolean; outputProps: { name: string; valueText: string }[]; } @@ -59,6 +60,7 @@ interface OptionFindings { lost: string[]; generatorProps: RuleProp[]; parserProps: RuleProp[]; + cssSourceMapOff: boolean; } interface UseSwap { @@ -211,7 +213,12 @@ class CssMigration { findings.lost.push(`${loaderName}.${name ?? "options"}`); continue; } - if (name === "url" || name === "import") { + if (name === "sourceMap") { + // `true` means "follow devtool", which is native behavior; `false` + // becomes a per-type `devtool` entry on the enclosing config. + if (value.kind() === "false") findings.cssSourceMapOff = true; + else if (value.kind() !== "true") findings.lost.push(`${loaderName}.sourceMap`); + } else if (name === "url" || name === "import") { // Booleans map to the rule's parser; filter functions have no equivalent. if (value.kind() === "true" || value.kind() === "false") { findings.parserProps.push({ name, valueText: value.text() }); @@ -342,10 +349,20 @@ class CssMigration { // fragments pushed into another tool's config (Storybook, craco, …). if (!arrayNode || arrayNode.kind() !== "array") continue; if (!this.isWebpackRuleContext(pair, arrayNode)) continue; - const findings: OptionFindings = { lost: [], generatorProps: [], parserProps: [] }; + const findings: OptionFindings = { + lost: [], + generatorProps: [], + parserProps: [], + cssSourceMapOff: false, + }; for (const element of elements) { this.collectLoaderOptionFindings(element, ruleObject, findings); } + if (findings.cssSourceMapOff) { + const config = findConfigObjectFor(pair); + if (config) this.planFor(config).disableCssSourceMap = true; + else findings.lost.push("css-loader.sourceMap"); + } const lostOptions = [...new Set(findings.lost)]; const generatorProps = dedupeProps(findings.generatorProps); const parserProps = dedupeProps(findings.parserProps); @@ -554,7 +571,7 @@ class CssMigration { const key = config.range().start.index; let plan = this.configPlans.get(key); if (!plan) { - plan = { config, needsExperimentsCss: false, outputProps: [] }; + plan = { config, needsExperimentsCss: false, disableCssSourceMap: false, outputProps: [] }; this.configPlans.set(key, plan); } return plan; @@ -569,6 +586,7 @@ class CssMigration { if (plan.outputProps.length) { this.planObjectProps(plan.config, "output", plan.outputProps, topProperties); } + this.planCssDevtool(plan); if (topProperties.length) { // A fully-emptied config keeps its braces open for these properties. this.editor.keepBracesOpen(plan.config); @@ -579,6 +597,18 @@ class CssMigration { } } + // css-loader's `sourceMap: false` maps to a per-type `devtool` entry. With + // no devtool (or a non-string form) there is nothing to disable safely. + private planCssDevtool(plan: ConfigPlan): void { + if (!plan.disableCssSourceMap) return; + const value = findPair(plan.config, "devtool")?.field("value"); + if (!value || value.kind() !== "string") return; + this.editor.replace( + value, + `[{ type: "javascript", use: ${value.text()} }, { type: "css", use: false }]`, + ); + } + // Insert props into the config's `key` object, creating it when absent; an // existing non-object value (e.g. a variable) is left alone. private planObjectProps( diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js b/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js index 53fff89..9ef1d20 100644 --- a/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js +++ b/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js @@ -8,6 +8,7 @@ module.exports = { { loader: "css-loader", options: { + sourceMap: true, modules: { localIdentName: "[name]__[local]___[hash:base64:5]", exportOnlyLocals: false, diff --git a/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js index 2421c0a..8cd5b9f 100644 --- a/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js +++ b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js @@ -2,6 +2,7 @@ module.exports = { experiments: { css: true, }, + devtool: [{ type: "javascript", use: "source-map" }, { type: "css", use: false }], module: { rules: [ { diff --git a/codemods/css-plugins-to-native-css/tests/css-url-option/input.js b/codemods/css-plugins-to-native-css/tests/css-url-option/input.js index 868e416..30d779e 100644 --- a/codemods/css-plugins-to-native-css/tests/css-url-option/input.js +++ b/codemods/css-plugins-to-native-css/tests/css-url-option/input.js @@ -1,9 +1,10 @@ module.exports = { + devtool: "source-map", module: { rules: [ { test: /\.css$/, - use: ["style-loader", { loader: "css-loader", options: { url: false, import: false } }], + use: ["style-loader", { loader: "css-loader", options: { url: false, import: false, sourceMap: false } }], }, ], }, From 2fed215525aba3648d56aba3538fc5ae8df6eb29 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:22:15 -0500 Subject: [PATCH 46/59] fix: scope devtool to javascript only, css entries with use:false are no-ops --- codemods/css-plugins-to-native-css/src/workflow.ts | 10 ++++------ .../tests/css-url-option/expected.js | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index fe50f8c..dae2341 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -597,16 +597,14 @@ class CssMigration { } } - // css-loader's `sourceMap: false` maps to a per-type `devtool` entry. With - // no devtool (or a non-string form) there is nothing to disable safely. + // css-loader's `sourceMap: false` scopes a string `devtool` to javascript + // only — devtool array entries are additive, so omitting css disables its + // maps. With no devtool (or a non-string form) there is nothing to disable. private planCssDevtool(plan: ConfigPlan): void { if (!plan.disableCssSourceMap) return; const value = findPair(plan.config, "devtool")?.field("value"); if (!value || value.kind() !== "string") return; - this.editor.replace( - value, - `[{ type: "javascript", use: ${value.text()} }, { type: "css", use: false }]`, - ); + this.editor.replace(value, `[{ type: "javascript", use: ${value.text()} }]`); } // Insert props into the config's `key` object, creating it when absent; an diff --git a/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js index 8cd5b9f..c032820 100644 --- a/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js +++ b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js @@ -2,7 +2,7 @@ module.exports = { experiments: { css: true, }, - devtool: [{ type: "javascript", use: "source-map" }, { type: "css", use: false }], + devtool: [{ type: "javascript", use: "source-map" }], module: { rules: [ { From 72454f25d6e22e9c71cb4837efb91275ec0742d4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:25:31 -0500 Subject: [PATCH 47/59] fix: gate modules.auto on literal true, map localIdentHash options to generator --- codemods/css-plugins-to-native-css/src/workflow.ts | 8 +++++++- .../tests/css-modules-options/expected.js | 2 +- .../tests/css-modules-options/input.js | 2 ++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index dae2341..3557a2b 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -260,9 +260,15 @@ class CssMigration { } switch (subName) { case "auto": + // Only `auto: true` matches the native `*.module.*` convention. + if (subValue.kind() !== "true") findings.lost.push("css-loader.modules.auto"); break; case "localIdentName": - findings.generatorProps.push({ name: "localIdentName", valueText: subValue.text() }); + case "localIdentHashSalt": + case "localIdentHashFunction": + case "localIdentHashDigest": + case "localIdentHashDigestLength": + findings.generatorProps.push({ name: subName, valueText: subValue.text() }); break; case "exportOnlyLocals": findings.generatorProps.push({ name: "exportsOnly", valueText: subValue.text() }); diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js b/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js index 9584dfc..c9faceb 100644 --- a/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js +++ b/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js @@ -8,7 +8,7 @@ module.exports = { test: /\.module\.css$/, // Removed loader options without a native CSS equivalent: css-loader.modules.mode type: "css/auto", - generator: { localIdentName: "[name]__[local]___[hash:base64:5]", exportsOnly: false, exportsConvention: "camel-case" }, + generator: { localIdentName: "[name]__[local]___[hash:base64:5]", localIdentHashSalt: "app-styles", exportsOnly: false, exportsConvention: "camel-case" }, parser: { namedExports: true }, }, ], diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js b/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js index 9ef1d20..909795e 100644 --- a/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js +++ b/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js @@ -10,7 +10,9 @@ module.exports = { options: { sourceMap: true, modules: { + auto: true, localIdentName: "[name]__[local]___[hash:base64:5]", + localIdentHashSalt: "app-styles", exportOnlyLocals: false, namedExport: true, exportLocalsConvention: "camelCase", From 627c9fcf5b802b0aad19c5987ee1a9153638d406 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:27:56 -0500 Subject: [PATCH 48/59] docs: explain the loader publicPath drop and its manual migration path --- codemods/css-plugins-to-native-css/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index a3d4fbd..2d6e549 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -17,6 +17,8 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. +About the loader's `publicPath`: it usually existed to fix `url()` resolution inside extracted CSS, which native CSS handles by itself through `output.publicPath` — most builds need nothing. If you genuinely served CSS-referenced assets from a different base URL, set `generator.publicPath` on the matching asset rules instead. + Only rules the file demonstrably owns as webpack config are transformed (a `rules`/`oneOf` array, a `module` block, or an import of `mini-css-extract-plugin`). Rule fragments pushed into another tool's webpack config — Storybook's `webpackFinal`, craco, and similar — are never modified: there the config is a mutated parameter with no literal object to receive `experiments.css: true`, and the tool's own base config registers CSS rules that keep the `"auto"` default off, so a partial migration would break the build. ## Usage From ed8be01bc7788ac39a3426aa57fd007297552149 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:31:17 -0500 Subject: [PATCH 49/59] feat: drop redundant extract-loader publicPath silently, flag only real overrides --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 24 +++++++++++++++++ .../tests/cra-ejected/expected.js | 12 --------- .../tests/extract-public-path/expected.js | 17 ++++++++++++ .../tests/extract-public-path/input.js | 26 +++++++++++++++++++ 5 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/extract-public-path/input.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 2d6e549..d13764a 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -17,7 +17,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. -About the loader's `publicPath`: it usually existed to fix `url()` resolution inside extracted CSS, which native CSS handles by itself through `output.publicPath` — most builds need nothing. If you genuinely served CSS-referenced assets from a different base URL, set `generator.publicPath` on the matching asset rules instead. +About the loader's `publicPath`: relative values (`"../../"`) existed to fix `url()` resolution inside extracted CSS — native CSS handles that by itself, so they are dropped silently, as are values that just repeat `output.publicPath`. Only a genuinely different base URL is flagged with a comment; its manual home is `generator.publicPath` on the matching asset rules. Only rules the file demonstrably owns as webpack config are transformed (a `rules`/`oneOf` array, a `module` block, or an import of `mini-css-extract-plugin`). Rule fragments pushed into another tool's webpack config — Storybook's `webpackFinal`, craco, and similar — are never modified: there the config is a mutated parameter with no literal object to receive `experiments.css: true`, and the tool's own base config registers CSS rules that keep the `"auto"` default off, so a partial migration would break the build. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 3557a2b..2973994 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -209,6 +209,12 @@ class CssMigration { const name = keyName(optionPair); const value = optionPair.field("value"); if (name !== null && droppable.has(name)) continue; + if (loaderName === EXTRACT_LOADER_NAME && name === "publicPath" && value) { + if (!this.isRedundantExtractPublicPath(value, ruleObject)) { + findings.lost.push(`${EXTRACT_LOADER_NAME}.publicPath`); + } + continue; + } if (loaderName !== "css-loader" || name === null || !value) { findings.lost.push(`${loaderName}.${name ?? "options"}`); continue; @@ -294,6 +300,24 @@ class CssMigration { } } + // The loader `publicPath` is redundant when it is the extraction-relative + // workaround ("./", "../") native CSS does not need, or when it just repeats + // the config's own `output.publicPath`. + private isRedundantExtractPublicPath(value: SgNode, ruleObject: SgNode): boolean { + if (value.kind() !== "string") return false; + const text = unquote(value.text()); + if (text.startsWith(".")) return true; + const config = findConfigObjectFor(ruleObject); + const outputValue = config ? findPair(config, "output")?.field("value") : undefined; + const publicPath = + outputValue && outputValue.kind() === "object" + ? findPair(outputValue, "publicPath")?.field("value") + : undefined; + return Boolean( + publicPath && publicPath.kind() === "string" && unquote(publicPath.text()) === text, + ); + } + // The plugin instantiation behind a plugins element, unwrapping guards. private pluginInstantiationOf(element: SgNode): SgNode | null { const branches = guardBranchesOf(element); diff --git a/codemods/css-plugins-to-native-css/tests/cra-ejected/expected.js b/codemods/css-plugins-to-native-css/tests/cra-ejected/expected.js index 4411bce..6c19cac 100644 --- a/codemods/css-plugins-to-native-css/tests/cra-ejected/expected.js +++ b/codemods/css-plugins-to-native-css/tests/cra-ejected/expected.js @@ -2,20 +2,8 @@ const isEnvDevelopment = process.env.NODE_ENV === "development"; const isEnvProduction = process.env.NODE_ENV === "production"; module.exports = { - experiments: { - css: true, - }, output: { cssFilename: "static/css/[name].[contenthash:8].css", cssChunkFilename: "static/css/[name].[contenthash:8].chunk.css", }, - module: { - rules: [ - { - test: /\.css$/, - // Removed loader options without a native CSS equivalent: MiniCssExtractPlugin.loader.publicPath - type: "css/auto", - }, - ], - }, }; diff --git a/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js b/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js new file mode 100644 index 0000000..30f5fab --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js @@ -0,0 +1,17 @@ +module.exports = { + experiments: { + css: true, + }, + output: { + publicPath: "/app/", + }, + module: { + rules: [ + { + test: /\.css$/, + // Removed loader options without a native CSS equivalent: MiniCssExtractPlugin.loader.publicPath + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/extract-public-path/input.js b/codemods/css-plugins-to-native-css/tests/extract-public-path/input.js new file mode 100644 index 0000000..836e54b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/extract-public-path/input.js @@ -0,0 +1,26 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + output: { + publicPath: "/app/", + }, + module: { + rules: [ + { + test: /\.css$/, + use: [ + { loader: MiniCssExtractPlugin.loader, options: { publicPath: "https://cdn.example.com/" } }, + "css-loader", + ], + }, + { + test: /\.module\.css$/, + use: [ + { loader: MiniCssExtractPlugin.loader, options: { publicPath: "/app/" } }, + "css-loader", + ], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; From 16e30b4565d9c6cce965885df86bebe29ca0240e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:35:38 -0500 Subject: [PATCH 50/59] feat: translate custom extract-loader publicPath into an issuer-scoped asset rule --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 25 +++++++++++++++++-- .../tests/extract-public-path/expected.js | 2 +- packages/codemod-utils/src/index.ts | 5 ++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index d13764a..69895fd 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -17,7 +17,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. -About the loader's `publicPath`: relative values (`"../../"`) existed to fix `url()` resolution inside extracted CSS — native CSS handles that by itself, so they are dropped silently, as are values that just repeat `output.publicPath`. Only a genuinely different base URL is flagged with a comment; its manual home is `generator.publicPath` on the matching asset rules. +About the loader's `publicPath`: relative values (`"../../"`) existed to fix `url()` resolution inside extracted CSS — native CSS handles that by itself, so they are dropped silently, as are values that just repeat `output.publicPath`. A genuinely different base URL is translated into an issuer-scoped asset rule (`{ issuer: , generator: { publicPath: … } }`) so assets referenced from those styles keep their custom URL; only non-literal values fall back to a review comment. Only rules the file demonstrably owns as webpack config are transformed (a `rules`/`oneOf` array, a `module` block, or an import of `mini-css-extract-plugin`). Rule fragments pushed into another tool's webpack config — Storybook's `webpackFinal`, craco, and similar — are never modified: there the config is a mutated parameter with no literal object to receive `experiments.css: true`, and the tool's own base config registers CSS rules that keep the `"auto"` default off, so a partial migration would break the build. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 2973994..44251a8 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -61,6 +61,7 @@ interface OptionFindings { generatorProps: RuleProp[]; parserProps: RuleProp[]; cssSourceMapOff: boolean; + cssPublicPath: string | null; } interface UseSwap { @@ -71,6 +72,7 @@ interface UseSwap { lostOptions: string[]; generatorProps: RuleProp[]; parserProps: RuleProp[]; + cssPublicPath: string | null; } interface RulesArrayWork { @@ -210,7 +212,13 @@ class CssMigration { const value = optionPair.field("value"); if (name !== null && droppable.has(name)) continue; if (loaderName === EXTRACT_LOADER_NAME && name === "publicPath" && value) { - if (!this.isRedundantExtractPublicPath(value, ruleObject)) { + // A genuinely different base URL becomes an issuer-scoped asset rule; + // non-literal values can't be carried over safely. + if (this.isRedundantExtractPublicPath(value, ruleObject)) { + // Extraction-relative workaround — native CSS resolves this itself. + } else if (value.kind() === "string") { + findings.cssPublicPath ??= value.text(); + } else { findings.lost.push(`${EXTRACT_LOADER_NAME}.publicPath`); } continue; @@ -384,6 +392,7 @@ class CssMigration { generatorProps: [], parserProps: [], cssSourceMapOff: false, + cssPublicPath: null, }; for (const element of elements) { this.collectLoaderOptionFindings(element, ruleObject, findings); @@ -413,7 +422,8 @@ class CssMigration { kept.length > 0 || lostOptions.length > 0 || generatorProps.length > 0 || - parserProps.length > 0; + parserProps.length > 0 || + findings.cssPublicPath !== null; if (!survives) { work.removedElements.push(ruleObject); } else { @@ -425,6 +435,7 @@ class CssMigration { lostOptions, generatorProps, parserProps, + cssPublicPath: findings.cssPublicPath, }); } } @@ -482,6 +493,16 @@ class CssMigration { replacement += `${separator}${key}: { ${texts.join(", ")} }`; } this.editor.replace(swap.pair, replacement); + if (swap.cssPublicPath !== null) { + // Assets referenced from this rule's files keep their custom base URL. + const issuer = findPair(swap.ruleObject, "test")?.field("value")?.text() ?? "/\\.css$/"; + const ruleIndent = lineIndent(this.editor.source, swap.ruleObject.range().start.index); + const sibling = `{ issuer: ${issuer}, generator: { publicPath: ${swap.cssPublicPath} } }`; + this.editor.insertAfter( + swap.ruleObject, + multiline ? `,\n${ruleIndent}${sibling}` : `, ${sibling}`, + ); + } // A surviving rule that matches `.css` turns the "auto" default off. if (!ruleMatchesFiles(swap.ruleObject, CSS_SAMPLE_FILES)) return; const config = findConfigObjectFor(swap.pair); diff --git a/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js b/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js index 30f5fab..c3c2579 100644 --- a/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js +++ b/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js @@ -9,9 +9,9 @@ module.exports = { rules: [ { test: /\.css$/, - // Removed loader options without a native CSS equivalent: MiniCssExtractPlugin.loader.publicPath type: "css/auto", }, + { issuer: /\.css$/, generator: { publicPath: "https://cdn.example.com/" } }, ], }, }; diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index 254e330..fa42165 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -112,6 +112,11 @@ export class ConfigEditor { this.edits.push(edit); } + insertAfter(node: SgNode, text: string): void { + const position = node.range().end.index; + this.edits.push({ startPos: position, endPos: position, insertedText: this.toSourceEol(text) }); + } + // Generated text may mix "\n" with source fragments that already carry the // file's EOL — normalize both so the conversion never doubles a "\r". private toSourceEol(text: string): string { From ae67ce04ca5a163ac9be3010e98a34df89dfc006 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:37:07 -0500 Subject: [PATCH 51/59] fix: skip issuer rule insertion when one is already declared --- .../css-plugins-to-native-css/src/workflow.ts | 25 +++++++++++++------ .../extract-public-path-existing/expected.js | 14 +++++++++++ .../extract-public-path-existing/input.js | 17 +++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/extract-public-path-existing/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/extract-public-path-existing/input.js diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 44251a8..a7540d1 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -308,6 +308,14 @@ class CssMigration { } } + private hasIssuerRule(arrayNode: SgNode | null, issuerText: string): boolean { + if (!arrayNode || arrayNode.kind() !== "array") return false; + return namedChildren(arrayNode).some((element) => { + if (element.kind() !== "object") return false; + return findPair(element, "issuer")?.field("value")?.text() === issuerText; + }); + } + // The loader `publicPath` is redundant when it is the extraction-relative // workaround ("./", "../") native CSS does not need, or when it just repeats // the config's own `output.publicPath`. @@ -494,14 +502,17 @@ class CssMigration { } this.editor.replace(swap.pair, replacement); if (swap.cssPublicPath !== null) { - // Assets referenced from this rule's files keep their custom base URL. + // Assets referenced from this rule's files keep their custom base URL — + // unless a rule for that issuer is already declared. const issuer = findPair(swap.ruleObject, "test")?.field("value")?.text() ?? "/\\.css$/"; - const ruleIndent = lineIndent(this.editor.source, swap.ruleObject.range().start.index); - const sibling = `{ issuer: ${issuer}, generator: { publicPath: ${swap.cssPublicPath} } }`; - this.editor.insertAfter( - swap.ruleObject, - multiline ? `,\n${ruleIndent}${sibling}` : `, ${sibling}`, - ); + if (!this.hasIssuerRule(swap.ruleObject.parent(), issuer)) { + const ruleIndent = lineIndent(this.editor.source, swap.ruleObject.range().start.index); + const sibling = `{ issuer: ${issuer}, generator: { publicPath: ${swap.cssPublicPath} } }`; + this.editor.insertAfter( + swap.ruleObject, + multiline ? `,\n${ruleIndent}${sibling}` : `, ${sibling}`, + ); + } } // A surviving rule that matches `.css` turns the "auto" default off. if (!ruleMatchesFiles(swap.ruleObject, CSS_SAMPLE_FILES)) return; diff --git a/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/expected.js b/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/expected.js new file mode 100644 index 0000000..d1c881e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/expected.js @@ -0,0 +1,14 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + type: "css/auto", + }, + { issuer: /\.css$/, generator: { publicPath: "https://cdn.example.com/" } }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/input.js b/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/input.js new file mode 100644 index 0000000..76239a3 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/input.js @@ -0,0 +1,17 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [ + { loader: MiniCssExtractPlugin.loader, options: { publicPath: "https://cdn.example.com/" } }, + "css-loader", + ], + }, + { issuer: /\.css$/, generator: { publicPath: "https://cdn.example.com/" } }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; From 96ad1cab6cc0fe49f95497e4a3a76ddce3e1cac3 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:44:15 -0500 Subject: [PATCH 52/59] feat: wrap any non-array devtool and keep the explicit css entry --- codemods/css-plugins-to-native-css/README.md | 2 +- codemods/css-plugins-to-native-css/src/workflow.ts | 14 +++++++++----- .../tests/css-url-option/expected.js | 2 +- .../tests/devtool-variable/expected.js | 5 +++++ .../tests/devtool-variable/input.js | 13 +++++++++++++ 5 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/devtool-variable/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/devtool-variable/input.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 69895fd..63abb41 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -13,7 +13,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. - Migrates `MiniCssExtractPlugin.getCompilationHooks(...)` taps to the native runtime's `webpack.web.CssLoadingRuntimeModule.getCompilationHooks(...)`: `linkPreload`/`linkPrefetch` keep their name and signature, and `beforeTagInsert` maps to `linkInsert` — note its callback now receives `(source, chunk)` instead of `(source, varNames)`, so review taps that used the second argument. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. -- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns a string `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. Options native CSS cannot replicate — `exportType`, `modules.mode`/`getLocalIdent`, style-loader's `insert`/`attributes`, `MiniCssExtractPlugin.loader`'s `publicPath`, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. +- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns any non-array `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. Options native CSS cannot replicate — `exportType`, `modules.mode`/`getLocalIdent`, style-loader's `insert`/`attributes`, `MiniCssExtractPlugin.loader`'s `publicPath`, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index a7540d1..84aa1fb 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -659,14 +659,18 @@ class CssMigration { } } - // css-loader's `sourceMap: false` scopes a string `devtool` to javascript - // only — devtool array entries are additive, so omitting css disables its - // maps. With no devtool (or a non-string form) there is nothing to disable. + // css-loader's `sourceMap: false` scopes `devtool` per asset type. The css + // entry is a runtime no-op (array entries are additive) but documents the + // intent. Any non-array devtool expression wraps as the `use` value; + // `false` means no maps at all and an existing array is already explicit. private planCssDevtool(plan: ConfigPlan): void { if (!plan.disableCssSourceMap) return; const value = findPair(plan.config, "devtool")?.field("value"); - if (!value || value.kind() !== "string") return; - this.editor.replace(value, `[{ type: "javascript", use: ${value.text()} }]`); + if (!value || value.kind() === "false" || value.kind() === "array") return; + this.editor.replace( + value, + `[{ type: "javascript", use: ${value.text()} }, { type: "css", use: false }]`, + ); } // Insert props into the config's `key` object, creating it when absent; an diff --git a/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js index c032820..8cd5b9f 100644 --- a/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js +++ b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js @@ -2,7 +2,7 @@ module.exports = { experiments: { css: true, }, - devtool: [{ type: "javascript", use: "source-map" }], + devtool: [{ type: "javascript", use: "source-map" }, { type: "css", use: false }], module: { rules: [ { diff --git a/codemods/css-plugins-to-native-css/tests/devtool-variable/expected.js b/codemods/css-plugins-to-native-css/tests/devtool-variable/expected.js new file mode 100644 index 0000000..4fd327c --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/devtool-variable/expected.js @@ -0,0 +1,5 @@ +const mapsSetting = process.env.CI ? "source-map" : "eval"; + +module.exports = { + devtool: [{ type: "javascript", use: mapsSetting }, { type: "css", use: false }], +}; diff --git a/codemods/css-plugins-to-native-css/tests/devtool-variable/input.js b/codemods/css-plugins-to-native-css/tests/devtool-variable/input.js new file mode 100644 index 0000000..a74c55b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/devtool-variable/input.js @@ -0,0 +1,13 @@ +const mapsSetting = process.env.CI ? "source-map" : "eval"; + +module.exports = { + devtool: mapsSetting, + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { sourceMap: false } }], + }, + ], + }, +}; From e762b8656d1fb87c534bc498ddfaaefcfbb217d2 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:48:27 -0500 Subject: [PATCH 53/59] fix: only scope devtool when every css-loader rule disables source maps --- .../css-plugins-to-native-css/src/workflow.ts | 114 +++++++++++------- .../tests/mixed-sourcemaps/expected.js | 16 +++ .../tests/mixed-sourcemaps/input.js | 16 +++ 3 files changed, 105 insertions(+), 41 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/input.js diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 84aa1fb..9baecc7 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -46,7 +46,8 @@ const DROPPABLE_INJECTION_LOADER_OPTIONS = new Set(["esModule"]); interface ConfigPlan { config: SgNode; needsExperimentsCss: boolean; - disableCssSourceMap: boolean; + cssLoaderRules: number; + sourceMapOffRules: number; outputProps: { name: string; valueText: string }[]; } @@ -73,12 +74,14 @@ interface UseSwap { generatorProps: RuleProp[]; parserProps: RuleProp[]; cssPublicPath: string | null; + trivial: boolean; + sourceMapOff: boolean; + plan: ConfigPlan | null; } interface RulesArrayWork { arrayNode: SgNode; - removedElements: SgNode[]; - swaps: UseSwap[]; + entries: UseSwap[]; } interface PluginRemoval { @@ -352,12 +355,31 @@ class CssMigration { // ---------- module.rules ---------- // Trivial rules are dropped (the `experiments.css: "auto"` default takes - // over); surviving rules get `type: "css/auto"`. + // over); surviving rules get `type: "css/auto"`. Survival is decided after + // every rule was collected, so config-wide facts (mixed sourceMap settings) + // can pull a rule back in to carry its comment. private transformRules(usePairs: SgNode[]): void { const rulesWork = this.collectRulesWork(usePairs); for (const work of rulesWork.values()) { + const removed: SgNode[] = []; + const swaps: UseSwap[] = []; + for (const entry of work.entries) { + // Mixed per-rule sourceMap settings can't scope `devtool` — flag them. + if (entry.sourceMapOff && entry.plan && this.hasMixedSourceMaps(entry.plan)) { + entry.lostOptions = [...new Set([...entry.lostOptions, "css-loader.sourceMap"])]; + } + const survives = + !entry.trivial || + entry.keptLoaders.length > 0 || + entry.lostOptions.length > 0 || + entry.generatorProps.length > 0 || + entry.parserProps.length > 0 || + entry.cssPublicPath !== null; + if (survives) swaps.push(entry); + else removed.push(entry.ruleObject); + } const allElements = namedChildren(work.arrayNode); - if (work.removedElements.length === allElements.length && !work.swaps.length) { + if (removed.length === allElements.length && !swaps.length) { const target = cascadeRemovalTarget(work.arrayNode); // A standalone array (assignment/declarator value) empties in place — // grouped removal would strip the `= [...]` and leave a bare reference. @@ -368,15 +390,19 @@ class CssMigration { } continue; } - for (const element of work.removedElements) { + for (const element of removed) { this.editor.markForRemoval(element); } - for (const swap of work.swaps) { + for (const swap of swaps) { this.replaceUsePair(swap); } } } + private hasMixedSourceMaps(plan: ConfigPlan): boolean { + return plan.sourceMapOffRules > 0 && plan.sourceMapOffRules < plan.cssLoaderRules; + } + private collectRulesWork(usePairs: SgNode[]): Map { const rulesWork = new Map(); for (const pair of usePairs) { @@ -405,51 +431,50 @@ class CssMigration { for (const element of elements) { this.collectLoaderOptionFindings(element, ruleObject, findings); } - if (findings.cssSourceMapOff) { - const config = findConfigObjectFor(pair); - if (config) this.planFor(config).disableCssSourceMap = true; - else findings.lost.push("css-loader.sourceMap"); + const config = findConfigObjectFor(pair); + const plan = config ? this.planFor(config) : null; + if (plan && this.ruleUsesCssLoader(elements)) { + plan.cssLoaderRules += 1; + if (findings.cssSourceMapOff) plan.sourceMapOffRules += 1; + } else if (findings.cssSourceMapOff) { + findings.lost.push("css-loader.sourceMap"); } - const lostOptions = [...new Set(findings.lost)]; - const generatorProps = dedupeProps(findings.generatorProps); - const parserProps = dedupeProps(findings.parserProps); const key = arrayNode.range().start.index; let work = rulesWork.get(key); if (!work) { - work = { arrayNode, removedElements: [], swaps: [] }; + work = { arrayNode, entries: [] }; rulesWork.set(key, work); } - const trivialRule = pairsOf(ruleObject).every((rulePair) => { + const trivial = pairsOf(ruleObject).every((rulePair) => { const name = keyName(rulePair); return name === "test" || name === "use"; }); - // A rule with lost or translated options stays as a swap so the comment - // and the generator/parser properties have a home. - const survives = - !trivialRule || - kept.length > 0 || - lostOptions.length > 0 || - generatorProps.length > 0 || - parserProps.length > 0 || - findings.cssPublicPath !== null; - if (!survives) { - work.removedElements.push(ruleObject); - } else { - work.swaps.push({ - pair, - ruleObject, - keptLoaders: kept, - filterSuffix: filterSuffixOf(originalValue, value), - lostOptions, - generatorProps, - parserProps, - cssPublicPath: findings.cssPublicPath, - }); - } + work.entries.push({ + pair, + ruleObject, + keptLoaders: kept, + filterSuffix: filterSuffixOf(originalValue, value), + lostOptions: [...new Set(findings.lost)], + generatorProps: dedupeProps(findings.generatorProps), + parserProps: dedupeProps(findings.parserProps), + cssPublicPath: findings.cssPublicPath, + trivial, + sourceMapOff: findings.cssSourceMapOff, + plan, + }); } return rulesWork; } + private ruleUsesCssLoader(elements: SgNode[]): boolean { + const usesIt = (node: SgNode): boolean => { + const branches = guardBranchesOf(node); + if (branches) return branches.some(usesIt); + return loaderNameOf(node) === "css-loader"; + }; + return elements.some(usesIt); + } + // Webpack owns the rule when its array hangs on a `rules`/`oneOf` pair, the // config has a `module` ancestor, or the file imports the extract plugin. private isWebpackRuleContext(usePair: SgNode, arrayNode: SgNode): boolean { @@ -633,7 +658,13 @@ class CssMigration { const key = config.range().start.index; let plan = this.configPlans.get(key); if (!plan) { - plan = { config, needsExperimentsCss: false, disableCssSourceMap: false, outputProps: [] }; + plan = { + config, + needsExperimentsCss: false, + cssLoaderRules: 0, + sourceMapOffRules: 0, + outputProps: [], + }; this.configPlans.set(key, plan); } return plan; @@ -664,7 +695,8 @@ class CssMigration { // intent. Any non-array devtool expression wraps as the `use` value; // `false` means no maps at all and an existing array is already explicit. private planCssDevtool(plan: ConfigPlan): void { - if (!plan.disableCssSourceMap) return; + // Only when every css-loader rule in this config disabled its maps. + if (!plan.sourceMapOffRules || this.hasMixedSourceMaps(plan)) return; const value = findPair(plan.config, "devtool")?.field("value"); if (!value || value.kind() === "false" || value.kind() === "array") return; this.editor.replace( diff --git a/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js new file mode 100644 index 0000000..c49fd1d --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js @@ -0,0 +1,16 @@ +module.exports = { + experiments: { + css: true, + }, + devtool: "source-map", + module: { + rules: [ + { + test: /\.css$/, + include: "src", + // Removed loader options without a native CSS equivalent: css-loader.sourceMap + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/input.js b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/input.js new file mode 100644 index 0000000..890545e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/input.js @@ -0,0 +1,16 @@ +module.exports = { + devtool: "source-map", + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: ["style-loader", { loader: "css-loader", options: { sourceMap: false } }], + }, + { + test: /\.module\.css$/, + use: ["style-loader", "css-loader"], + }, + ], + }, +}; From f6c593daf5d444fdf4c67adeedbb4dfaea5c8a05 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:52:01 -0500 Subject: [PATCH 54/59] fix: insert config props after spreads so they are not overridden, cover ts configs --- .../tests/spread-config/expected.js | 20 ++++++++++ .../tests/spread-config/input.js | 16 ++++++++ .../tests/typescript-config/expected.ts | 5 +++ .../tests/typescript-config/input.ts | 16 ++++++++ packages/codemod-utils/src/index.ts | 37 +++++++++++++------ 5 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/spread-config/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/spread-config/input.js create mode 100644 codemods/css-plugins-to-native-css/tests/typescript-config/expected.ts create mode 100644 codemods/css-plugins-to-native-css/tests/typescript-config/input.ts diff --git a/codemods/css-plugins-to-native-css/tests/spread-config/expected.js b/codemods/css-plugins-to-native-css/tests/spread-config/expected.js new file mode 100644 index 0000000..13d91a0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/spread-config/expected.js @@ -0,0 +1,20 @@ +const base = require("./webpack.base.js"); + +module.exports = { + ...base, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + }, + ], + }, + experiments: { + css: true, + }, + output: { + cssFilename: "[name].css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/spread-config/input.js b/codemods/css-plugins-to-native-css/tests/spread-config/input.js new file mode 100644 index 0000000..1937451 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/spread-config/input.js @@ -0,0 +1,16 @@ +const base = require("./webpack.base.js"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + ...base, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "[name].css" })], +}; diff --git a/codemods/css-plugins-to-native-css/tests/typescript-config/expected.ts b/codemods/css-plugins-to-native-css/tests/typescript-config/expected.ts new file mode 100644 index 0000000..805ca08 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/typescript-config/expected.ts @@ -0,0 +1,5 @@ +import type { Configuration } from "webpack"; + +const config: Configuration = {}; + +export default config; diff --git a/codemods/css-plugins-to-native-css/tests/typescript-config/input.ts b/codemods/css-plugins-to-native-css/tests/typescript-config/input.ts new file mode 100644 index 0000000..0e8a529 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/typescript-config/input.ts @@ -0,0 +1,16 @@ +import MiniCssExtractPlugin from "mini-css-extract-plugin"; +import type { Configuration } from "webpack"; + +const config: Configuration = { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; + +export default config; diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index fa42165..70beff8 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -160,27 +160,40 @@ export class ConfigEditor { } } - // Insert properties right after an object's opening brace, matching its layout. + // Insert properties into an object, matching its layout. They go right after + // the opening brace — except when the object contains spreads, where a later + // spread could override them: then they go after the last property instead. insertIntoObject( objectNode: SgNode, buildProperties: (indent: string, indentUnit: string) => string[], ): void { - const insertAt = objectNode.range().start.index + 1; const properties = namedChildren(objectNode); const multiline = objectNode.text().includes("\n") && properties.length > 0; + const indent = multiline ? lineIndent(this.source, properties[0].range().start.index) : ""; + // One indentation step: the first property's indent minus the object's own. + const objectIndent = lineIndent(this.source, objectNode.range().start.index); + const indentUnit = indent.slice(objectIndent.length) || (indent.includes("\t") ? "\t" : " "); + const built = buildProperties(indent, indentUnit); + const hasSpread = properties.some((property) => property.kind() === "spread_element"); + let insertAt: number; let insertedText: string; - if (multiline) { - const indent = lineIndent(this.source, properties[0].range().start.index); - // One indentation step: the first property's indent minus the object's own. - const objectIndent = lineIndent(this.source, objectNode.range().start.index); - const indentUnit = indent.slice(objectIndent.length) || (indent.includes("\t") ? "\t" : " "); - insertedText = buildProperties(indent, indentUnit) - .map((property) => `\n${indent}${property},`) + if (hasSpread) { + insertAt = properties[properties.length - 1].range().end.index; + const hasComma = this.source[insertAt] === ","; + if (hasComma) insertAt += 1; + insertedText = built + .map((property) => (multiline ? `\n${indent}${property},` : ` ${property},`)) .join(""); - } else if (properties.length) { - insertedText = ` ${buildProperties("", "").join(", ")},`; + if (!hasComma) insertedText = `,${insertedText}`; } else { - insertedText = ` ${buildProperties("", "").join(", ")} `; + insertAt = objectNode.range().start.index + 1; + if (multiline) { + insertedText = built.map((property) => `\n${indent}${property},`).join(""); + } else if (properties.length) { + insertedText = ` ${built.join(", ")},`; + } else { + insertedText = ` ${built.join(", ")} `; + } } this.edits.push({ startPos: insertAt, From dfd198086ceb0584cc1749c1ff7b8c8cbb974157 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 22:57:01 -0500 Subject: [PATCH 55/59] feat: support rule-level loader/options shorthand, cover defineConfig --- .../css-plugins-to-native-css/src/workflow.ts | 76 ++++++++++++++++++- .../tests/define-config/expected.js | 19 +++++ .../tests/define-config/input.js | 15 ++++ .../tests/loader-shorthand/expected.js | 15 ++++ .../tests/loader-shorthand/input.js | 12 +++ 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/define-config/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/define-config/input.js create mode 100644 codemods/css-plugins-to-native-css/tests/loader-shorthand/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/loader-shorthand/input.js diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 9baecc7..47a33c3 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -77,6 +77,8 @@ interface UseSwap { trivial: boolean; sourceMapOff: boolean; plan: ConfigPlan | null; + // Rule-level `options:` sibling of a `loader:` shorthand, removed on swap. + optionsPair: SgNode | null; } interface RulesArrayWork { @@ -111,13 +113,15 @@ class CssMigration { run(): string | null { const usePairs: SgNode[] = []; + const loaderPairs: SgNode[] = []; const pluginsPairs: SgNode[] = []; for (const pair of this.editor.rootNode.findAll({ rule: { kind: "pair" } })) { const name = keyName(pair); if (name === "use") usePairs.push(pair); + else if (name === "loader") loaderPairs.push(pair); else if (name === "plugins") pluginsPairs.push(pair); } - this.transformRules(usePairs); + this.transformRules(usePairs, loaderPairs); this.transformPlugins(pluginsPairs); this.migrateCompilationHooks(); this.planConfigInsertions(); @@ -358,8 +362,8 @@ class CssMigration { // over); surviving rules get `type: "css/auto"`. Survival is decided after // every rule was collected, so config-wide facts (mixed sourceMap settings) // can pull a rule back in to carry its comment. - private transformRules(usePairs: SgNode[]): void { - const rulesWork = this.collectRulesWork(usePairs); + private transformRules(usePairs: SgNode[], loaderPairs: SgNode[]): void { + const rulesWork = this.collectRulesWork(usePairs, loaderPairs); for (const work of rulesWork.values()) { const removed: SgNode[] = []; const swaps: UseSwap[] = []; @@ -403,7 +407,10 @@ class CssMigration { return plan.sourceMapOffRules > 0 && plan.sourceMapOffRules < plan.cssLoaderRules; } - private collectRulesWork(usePairs: SgNode[]): Map { + private collectRulesWork( + usePairs: SgNode[], + loaderPairs: SgNode[], + ): Map { const rulesWork = new Map(); for (const pair of usePairs) { const originalValue = pair.field("value"); @@ -461,11 +468,71 @@ class CssMigration { trivial, sourceMapOff: findings.cssSourceMapOff, plan, + optionsPair: null, }); } + this.collectLoaderShorthandWork(loaderPairs, rulesWork); return rulesWork; } + // Rule-level `loader:`/`options:` shorthand — the rule object itself has the + // `{ loader, options }` shape the option findings already understand. + private collectLoaderShorthandWork( + loaderPairs: SgNode[], + rulesWork: Map, + ): void { + for (const pair of loaderPairs) { + const ruleObject = pair.parent(); + if (!ruleObject || ruleObject.kind() !== "object") continue; + if (!findPair(ruleObject, "test") || findPair(ruleObject, "use")) continue; + const value = pair.field("value"); + if (!value || !this.isRemovableUseElement(value)) continue; + const arrayNode = ruleObject.parent(); + if (!arrayNode || arrayNode.kind() !== "array") continue; + if (!this.isWebpackRuleContext(pair, arrayNode)) continue; + const findings: OptionFindings = { + lost: [], + generatorProps: [], + parserProps: [], + cssSourceMapOff: false, + cssPublicPath: null, + }; + this.collectLoaderOptionFindings(ruleObject, ruleObject, findings); + const config = findConfigObjectFor(pair); + const plan = config ? this.planFor(config) : null; + if (plan && this.ruleUsesCssLoader([ruleObject])) { + plan.cssLoaderRules += 1; + if (findings.cssSourceMapOff) plan.sourceMapOffRules += 1; + } else if (findings.cssSourceMapOff) { + findings.lost.push("css-loader.sourceMap"); + } + const key = arrayNode.range().start.index; + let work = rulesWork.get(key); + if (!work) { + work = { arrayNode, entries: [] }; + rulesWork.set(key, work); + } + const trivial = pairsOf(ruleObject).every((rulePair) => { + const name = keyName(rulePair); + return name === "test" || name === "loader" || name === "options"; + }); + work.entries.push({ + pair, + ruleObject, + keptLoaders: [], + filterSuffix: "", + lostOptions: [...new Set(findings.lost)], + generatorProps: dedupeProps(findings.generatorProps), + parserProps: dedupeProps(findings.parserProps), + cssPublicPath: findings.cssPublicPath, + trivial, + sourceMapOff: findings.cssSourceMapOff, + plan, + optionsPair: findPair(ruleObject, "options") ?? null, + }); + } + } + private ruleUsesCssLoader(elements: SgNode[]): boolean { const usesIt = (node: SgNode): boolean => { const branches = guardBranchesOf(node); @@ -526,6 +593,7 @@ class CssMigration { replacement += `${separator}${key}: { ${texts.join(", ")} }`; } this.editor.replace(swap.pair, replacement); + if (swap.optionsPair) this.editor.markForRemoval(swap.optionsPair); if (swap.cssPublicPath !== null) { // Assets referenced from this rule's files keep their custom base URL — // unless a rule for that issuer is already declared. diff --git a/codemods/css-plugins-to-native-css/tests/define-config/expected.js b/codemods/css-plugins-to-native-css/tests/define-config/expected.js new file mode 100644 index 0000000..05eedfe --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/define-config/expected.js @@ -0,0 +1,19 @@ +const { defineConfig } = require("webpack"); + +module.exports = defineConfig({ + experiments: { + css: true, + }, + output: { + cssFilename: "[name].css", + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + }, + ], + }, +}); diff --git a/codemods/css-plugins-to-native-css/tests/define-config/input.js b/codemods/css-plugins-to-native-css/tests/define-config/input.js new file mode 100644 index 0000000..3ccde59 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/define-config/input.js @@ -0,0 +1,15 @@ +const { defineConfig } = require("webpack"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = defineConfig({ + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "[name].css" })], +}); diff --git a/codemods/css-plugins-to-native-css/tests/loader-shorthand/expected.js b/codemods/css-plugins-to-native-css/tests/loader-shorthand/expected.js new file mode 100644 index 0000000..ae50820 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/loader-shorthand/expected.js @@ -0,0 +1,15 @@ +module.exports = { + experiments: { + css: true, + }, + target: "node", + module: { + rules: [ + { + test: /\.module\.css$/, + type: "css/auto", + generator: { exportsOnly: true }, + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/loader-shorthand/input.js b/codemods/css-plugins-to-native-css/tests/loader-shorthand/input.js new file mode 100644 index 0000000..0e95f44 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/loader-shorthand/input.js @@ -0,0 +1,12 @@ +module.exports = { + target: "node", + module: { + rules: [ + { + test: /\.module\.css$/, + loader: "css-loader", + options: { modules: { exportOnlyLocals: true } }, + }, + ], + }, +}; From b98c766a0a48ab3dba6e798291a326ef19fb4b5d Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:05:05 -0500 Subject: [PATCH 56/59] feat: translate exportType, extract-loader emit:false, and modules.mode natively --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 39 ++++++++++++++++++- .../tests/advanced-options/expected.js | 20 ++++++++++ .../tests/advanced-options/input.js | 21 ++++++++++ .../tests/css-modules-options/expected.js | 1 - 5 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/advanced-options/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/advanced-options/input.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 63abb41..7ecefbe 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -13,7 +13,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. - Migrates `MiniCssExtractPlugin.getCompilationHooks(...)` taps to the native runtime's `webpack.web.CssLoadingRuntimeModule.getCompilationHooks(...)`: `linkPreload`/`linkPrefetch` keep their name and signature, and `beforeTagInsert` maps to `linkInsert` — note its callback now receives `(source, chunk)` instead of `(source, varNames)`, so review taps that used the second argument. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. -- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns any non-array `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. Options native CSS cannot replicate — `exportType`, `modules.mode`/`getLocalIdent`, style-loader's `insert`/`attributes`, `MiniCssExtractPlugin.loader`'s `publicPath`, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. +- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns any non-array `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. More translations: `exportType: "css-style-sheet"`/`"string"` → `parser.exportType`, the extract loader's `emit: false` (SSR) → `generator: { exportsOnly: true }`, and `modules.mode` → `type: "css/global"` for `"global"` / `parser: { pure: true }` for `"pure"`. Options native CSS cannot replicate — `modules.getLocalIdent`, style-loader's `insert`/`attributes`, non-literal values, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 47a33c3..d0ef283 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -63,6 +63,8 @@ interface OptionFindings { parserProps: RuleProp[]; cssSourceMapOff: boolean; cssPublicPath: string | null; + // Module type overriding the default `css/auto` (e.g. `css/global`). + cssType: string | null; } interface UseSwap { @@ -74,6 +76,7 @@ interface UseSwap { generatorProps: RuleProp[]; parserProps: RuleProp[]; cssPublicPath: string | null; + cssType: string | null; trivial: boolean; sourceMapOff: boolean; plan: ConfigPlan | null; @@ -218,6 +221,15 @@ class CssMigration { const name = keyName(optionPair); const value = optionPair.field("value"); if (name !== null && droppable.has(name)) continue; + if (loaderName === EXTRACT_LOADER_NAME && name === "emit" && value) { + // `emit: false` (SSR) maps to the native exports-only generator. + if (value.kind() === "false") { + findings.generatorProps.push({ name: "exportsOnly", valueText: "true" }); + } else if (value.kind() !== "true") { + findings.lost.push(`${EXTRACT_LOADER_NAME}.emit`); + } + continue; + } if (loaderName === EXTRACT_LOADER_NAME && name === "publicPath" && value) { // A genuinely different base URL becomes an issuer-scoped asset rule; // non-literal values can't be carried over safely. @@ -239,6 +251,15 @@ class CssMigration { // becomes a per-type `devtool` entry on the enclosing config. if (value.kind() === "false") findings.cssSourceMapOff = true; else if (value.kind() !== "true") findings.lost.push(`${loaderName}.sourceMap`); + } else if (name === "exportType") { + // "array" only described the loader-chain format that is now gone. + const exportType = value.kind() === "string" ? unquote(value.text()) : null; + if (exportType === "css-style-sheet" || exportType === "string") { + const mapped = exportType === "string" ? "text" : exportType; + findings.parserProps.push({ name: "exportType", valueText: `"${mapped}"` }); + } else if (exportType !== "array") { + findings.lost.push(`${loaderName}.exportType`); + } } else if (name === "url" || name === "import") { // Booleans map to the rule's parser; filter functions have no equivalent. if (value.kind() === "true" || value.kind() === "false") { @@ -284,6 +305,15 @@ class CssMigration { // Only `auto: true` matches the native `*.module.*` convention. if (subValue.kind() !== "true") findings.lost.push("css-loader.modules.auto"); break; + case "mode": { + // "local" is what `css/auto` already does for these rules; "global" + // and "pure" have dedicated native forms; functions do not. + const mode = subValue.kind() === "string" ? unquote(subValue.text()) : null; + if (mode === "global") findings.cssType = "css/global"; + else if (mode === "pure") findings.parserProps.push({ name: "pure", valueText: "true" }); + else if (mode !== "local") findings.lost.push("css-loader.modules.mode"); + break; + } case "localIdentName": case "localIdentHashSalt": case "localIdentHashFunction": @@ -378,7 +408,8 @@ class CssMigration { entry.lostOptions.length > 0 || entry.generatorProps.length > 0 || entry.parserProps.length > 0 || - entry.cssPublicPath !== null; + entry.cssPublicPath !== null || + entry.cssType !== null; if (survives) swaps.push(entry); else removed.push(entry.ruleObject); } @@ -434,6 +465,7 @@ class CssMigration { parserProps: [], cssSourceMapOff: false, cssPublicPath: null, + cssType: null, }; for (const element of elements) { this.collectLoaderOptionFindings(element, ruleObject, findings); @@ -465,6 +497,7 @@ class CssMigration { generatorProps: dedupeProps(findings.generatorProps), parserProps: dedupeProps(findings.parserProps), cssPublicPath: findings.cssPublicPath, + cssType: findings.cssType, trivial, sourceMapOff: findings.cssSourceMapOff, plan, @@ -496,6 +529,7 @@ class CssMigration { parserProps: [], cssSourceMapOff: false, cssPublicPath: null, + cssType: null, }; this.collectLoaderOptionFindings(ruleObject, ruleObject, findings); const config = findConfigObjectFor(pair); @@ -525,6 +559,7 @@ class CssMigration { generatorProps: dedupeProps(findings.generatorProps), parserProps: dedupeProps(findings.parserProps), cssPublicPath: findings.cssPublicPath, + cssType: findings.cssType, trivial, sourceMapOff: findings.cssSourceMapOff, plan, @@ -583,7 +618,7 @@ class CssMigration { const filterSuffix = keepsGuard ? swap.filterSuffix || ".filter(Boolean)" : ""; replacement += `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}`; } - replacement += 'type: "css/auto"'; + replacement += `type: "${swap.cssType ?? "css/auto"}"`; for (const [key, props] of [ ["generator", swap.generatorProps], ["parser", swap.parserProps], diff --git a/codemods/css-plugins-to-native-css/tests/advanced-options/expected.js b/codemods/css-plugins-to-native-css/tests/advanced-options/expected.js new file mode 100644 index 0000000..13f2a8b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/advanced-options/expected.js @@ -0,0 +1,20 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + generator: { exportsOnly: true }, + parser: { exportType: "css-style-sheet" }, + }, + { + test: /\.module\.css$/, + type: "css/global", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/advanced-options/input.js b/codemods/css-plugins-to-native-css/tests/advanced-options/input.js new file mode 100644 index 0000000..d5d677b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/advanced-options/input.js @@ -0,0 +1,21 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [ + { loader: MiniCssExtractPlugin.loader, options: { emit: false } }, + { loader: "css-loader", options: { exportType: "css-style-sheet" } }, + ], + }, + { + test: /\.module\.css$/, + use: ["style-loader", { loader: "css-loader", options: { modules: { mode: "global" } } }], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js b/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js index c9faceb..3b229f5 100644 --- a/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js +++ b/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js @@ -6,7 +6,6 @@ module.exports = { rules: [ { test: /\.module\.css$/, - // Removed loader options without a native CSS equivalent: css-loader.modules.mode type: "css/auto", generator: { localIdentName: "[name]__[local]___[hash:base64:5]", localIdentHashSalt: "app-styles", exportsOnly: false, exportsConvention: "camel-case" }, parser: { namedExports: true }, From 151e58d754ac8382ee4da3fd7ac3d83379aa2afb Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:10:41 -0500 Subject: [PATCH 57/59] feat: translate style-loader crossorigin attribute to output.crossOriginLoading --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 39 +++++++++++++++++++ .../tests/style-attributes/expected.js | 18 +++++++++ .../tests/style-attributes/input.js | 14 +++++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 codemods/css-plugins-to-native-css/tests/style-attributes/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/style-attributes/input.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 7ecefbe..30b4a11 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -13,7 +13,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. - Migrates `MiniCssExtractPlugin.getCompilationHooks(...)` taps to the native runtime's `webpack.web.CssLoadingRuntimeModule.getCompilationHooks(...)`: `linkPreload`/`linkPrefetch` keep their name and signature, and `beforeTagInsert` maps to `linkInsert` — note its callback now receives `(source, chunk)` instead of `(source, varNames)`, so review taps that used the second argument. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. -- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns any non-array `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. More translations: `exportType: "css-style-sheet"`/`"string"` → `parser.exportType`, the extract loader's `emit: false` (SSR) → `generator: { exportsOnly: true }`, and `modules.mode` → `type: "css/global"` for `"global"` / `parser: { pure: true }` for `"pure"`. Options native CSS cannot replicate — `modules.getLocalIdent`, style-loader's `insert`/`attributes`, non-literal values, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. +- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns any non-array `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. More translations: `exportType: "css-style-sheet"`/`"string"` → `parser.exportType`, the extract loader's `emit: false` (SSR) → `generator: { exportsOnly: true }`, `modules.mode` → `type: "css/global"` for `"global"` / `parser: { pure: true }` for `"pure"`, and style-loader's `attributes.crossorigin` → `output.crossOriginLoading`. A flagged `attributes.nonce` migrates manually: set the `__webpack_nonce__` runtime global, or `output.html.csp.nonce` when webpack emits your HTML. Options native CSS cannot replicate — `modules.getLocalIdent`, style-loader's `insert`/`attributes`, non-literal values, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index d0ef283..576d840 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -65,6 +65,8 @@ interface OptionFindings { cssPublicPath: string | null; // Module type overriding the default `css/auto` (e.g. `css/global`). cssType: string | null; + // Props for the enclosing config's `output` (e.g. crossOriginLoading). + outputProps: RuleProp[]; } interface UseSwap { @@ -221,6 +223,10 @@ class CssMigration { const name = keyName(optionPair); const value = optionPair.field("value"); if (name !== null && droppable.has(name)) continue; + if (loaderName === "style-loader" && name === "attributes" && value) { + this.collectStyleAttributes(value, findings); + continue; + } if (loaderName === EXTRACT_LOADER_NAME && name === "emit" && value) { // `emit: false` (SSR) maps to the native exports-only generator. if (value.kind() === "false") { @@ -345,6 +351,35 @@ class CssMigration { } } + // Native css injection reads `output.crossOriginLoading`; `nonce` is served + // by the `__webpack_nonce__` runtime global, which config cannot set. + private collectStyleAttributes(value: SgNode, findings: OptionFindings): void { + if (value.kind() !== "object") { + findings.lost.push("style-loader.attributes"); + return; + } + for (const attribute of pairsOf(value)) { + const name = keyName(attribute); + const attrValue = attribute.field("value"); + const literal = attrValue && attrValue.kind() === "string" ? unquote(attrValue.text()) : null; + if (name === "crossorigin" && (literal === "anonymous" || literal === "use-credentials")) { + findings.outputProps.push({ name: "crossOriginLoading", valueText: `"${literal}"` }); + } else { + findings.lost.push(`style-loader.attributes.${name ?? "attributes"}`); + } + } + } + + private mergeOutputProps(plan: ConfigPlan | null, findings: OptionFindings): void { + for (const prop of findings.outputProps) { + if (!plan) { + findings.lost.push("style-loader.attributes.crossorigin"); + } else if (!plan.outputProps.some((existing) => existing.name === prop.name)) { + plan.outputProps.push(prop); + } + } + } + private hasIssuerRule(arrayNode: SgNode | null, issuerText: string): boolean { if (!arrayNode || arrayNode.kind() !== "array") return false; return namedChildren(arrayNode).some((element) => { @@ -466,6 +501,7 @@ class CssMigration { cssSourceMapOff: false, cssPublicPath: null, cssType: null, + outputProps: [], }; for (const element of elements) { this.collectLoaderOptionFindings(element, ruleObject, findings); @@ -478,6 +514,7 @@ class CssMigration { } else if (findings.cssSourceMapOff) { findings.lost.push("css-loader.sourceMap"); } + this.mergeOutputProps(plan, findings); const key = arrayNode.range().start.index; let work = rulesWork.get(key); if (!work) { @@ -530,6 +567,7 @@ class CssMigration { cssSourceMapOff: false, cssPublicPath: null, cssType: null, + outputProps: [], }; this.collectLoaderOptionFindings(ruleObject, ruleObject, findings); const config = findConfigObjectFor(pair); @@ -540,6 +578,7 @@ class CssMigration { } else if (findings.cssSourceMapOff) { findings.lost.push("css-loader.sourceMap"); } + this.mergeOutputProps(plan, findings); const key = arrayNode.range().start.index; let work = rulesWork.get(key); if (!work) { diff --git a/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js b/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js new file mode 100644 index 0000000..d0022d8 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js @@ -0,0 +1,18 @@ +module.exports = { + experiments: { + css: true, + }, + output: { + crossOriginLoading: "anonymous", + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + // Removed loader options without a native CSS equivalent: style-loader.attributes.nonce + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/style-attributes/input.js b/codemods/css-plugins-to-native-css/tests/style-attributes/input.js new file mode 100644 index 0000000..0322abd --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/style-attributes/input.js @@ -0,0 +1,14 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [ + { loader: "style-loader", options: { attributes: { crossorigin: "anonymous", nonce: "abc123" } } }, + "css-loader", + ], + }, + ], + }, +}; From b32279f99e3863db4c1bca4c0c06fa4e7ccf63d4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:14:05 -0500 Subject: [PATCH 58/59] feat: append manual migration hints to lost-option comments --- .../css-plugins-to-native-css/src/workflow.ts | 17 ++++++++++++++++- .../tests/mixed-sourcemaps/expected.js | 2 +- .../tests/style-attributes/expected.js | 2 +- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index 576d840..f6c58c7 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -38,6 +38,17 @@ const EXPORTS_CONVENTION_MAP = new Map([ ["dashes", "dashes"], ["dashesOnly", "dashes-only"], ]); +// Manual migration paths appended to the review comment where one exists. +const LOST_OPTION_HINTS = new Map([ + ["style-loader.insert", "tap webpack.web.CssLoadingRuntimeModule.getCompilationHooks().linkInsert"], + [ + "style-loader.styleTagTransform", + "tap webpack.web.CssLoadingRuntimeModule.getCompilationHooks().createStylesheet", + ], + ["style-loader.attributes.nonce", "set __webpack_nonce__ or output.html.csp.nonce"], + ["css-loader.sourceMap", "scope devtool entries per asset type"], + ["MiniCssExtractPlugin.loader.publicPath", "set generator.publicPath on the matching asset rules"], +]); // Options native CSS covers on its own; any other option is flagged when dropped. const DROPPABLE_CSS_LOADER_OPTIONS = new Set(["importLoaders", "esModule"]); const DROPPABLE_INJECTION_LOADER_OPTIONS = new Set(["esModule"]); @@ -645,7 +656,11 @@ class CssMigration { // Flag dropped loader options right where they lived. let commentPrefix = ""; if (swap.lostOptions.length) { - const message = `Removed loader options without a native CSS equivalent: ${swap.lostOptions.join(", ")}`; + const described = swap.lostOptions.map((name) => { + const hint = LOST_OPTION_HINTS.get(name); + return hint ? `${name} (${hint})` : name; + }); + const message = `Removed loader options without a native CSS equivalent: ${described.join(", ")}`; commentPrefix = multiline ? `// ${message}\n${indent}` : `/* ${message} */ `; } const separator = multiline ? `,\n${indent}` : ", "; diff --git a/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js index c49fd1d..1ab8e2e 100644 --- a/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js +++ b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js @@ -8,7 +8,7 @@ module.exports = { { test: /\.css$/, include: "src", - // Removed loader options without a native CSS equivalent: css-loader.sourceMap + // Removed loader options without a native CSS equivalent: css-loader.sourceMap (scope devtool entries per asset type) type: "css/auto", }, ], diff --git a/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js b/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js index d0022d8..3e04ddd 100644 --- a/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js +++ b/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js @@ -10,7 +10,7 @@ module.exports = { { test: /\.css$/, include: "src", - // Removed loader options without a native CSS equivalent: style-loader.attributes.nonce + // Removed loader options without a native CSS equivalent: style-loader.attributes.nonce (set __webpack_nonce__ or output.html.csp.nonce) type: "css/auto", }, ], From 32343088d0c7cac19031dbed148b7863257bc2a8 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:19:07 -0500 Subject: [PATCH 59/59] feat: carry the extract loader layer option to the native rule layer --- codemods/css-plugins-to-native-css/README.md | 2 +- .../css-plugins-to-native-css/src/workflow.ts | 34 +++++++++++++++++-- .../tests/layer-option/expected.js | 16 +++++++++ .../tests/layer-option/input.js | 17 ++++++++++ 4 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 codemods/css-plugins-to-native-css/tests/layer-option/expected.js create mode 100644 codemods/css-plugins-to-native-css/tests/layer-option/input.js diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md index 30b4a11..55cc643 100644 --- a/codemods/css-plugins-to-native-css/README.md +++ b/codemods/css-plugins-to-native-css/README.md @@ -13,7 +13,7 @@ Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader - Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. - Migrates `MiniCssExtractPlugin.getCompilationHooks(...)` taps to the native runtime's `webpack.web.CssLoadingRuntimeModule.getCompilationHooks(...)`: `linkPreload`/`linkPrefetch` keep their name and signature, and `beforeTagInsert` maps to `linkInsert` — note its callback now receives `(source, chunk)` instead of `(source, varNames)`, so review taps that used the second argument. - Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. -- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns any non-array `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. More translations: `exportType: "css-style-sheet"`/`"string"` → `parser.exportType`, the extract loader's `emit: false` (SSR) → `generator: { exportsOnly: true }`, `modules.mode` → `type: "css/global"` for `"global"` / `parser: { pure: true }` for `"pure"`, and style-loader's `attributes.crossorigin` → `output.crossOriginLoading`. A flagged `attributes.nonce` migrates manually: set the `__webpack_nonce__` runtime global, or `output.html.csp.nonce` when webpack emits your HTML. Options native CSS cannot replicate — `modules.getLocalIdent`, style-loader's `insert`/`attributes`, non-literal values, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. +- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns any non-array `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. More translations: `exportType: "css-style-sheet"`/`"string"` → `parser.exportType`, the extract loader's `emit: false` (SSR) → `generator: { exportsOnly: true }`, `modules.mode` → `type: "css/global"` for `"global"` / `parser: { pure: true }` for `"pure"`, style-loader's `attributes.crossorigin` → `output.crossOriginLoading`, and the extract loader's `layer` → the rule-level `layer` (enabling `experiments.layers`). A flagged `attributes.nonce` migrates manually: set the `__webpack_nonce__` runtime global, or `output.html.csp.nonce` when webpack emits your HTML. Options native CSS cannot replicate — `modules.getLocalIdent`, style-loader's `insert`/`attributes`, non-literal values, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts index f6c58c7..50942bd 100644 --- a/codemods/css-plugins-to-native-css/src/workflow.ts +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -57,6 +57,7 @@ const DROPPABLE_INJECTION_LOADER_OPTIONS = new Set(["esModule"]); interface ConfigPlan { config: SgNode; needsExperimentsCss: boolean; + needsLayers: boolean; cssLoaderRules: number; sourceMapOffRules: number; outputProps: { name: string; valueText: string }[]; @@ -78,6 +79,8 @@ interface OptionFindings { cssType: string | null; // Props for the enclosing config's `output` (e.g. crossOriginLoading). outputProps: RuleProp[]; + // Rule-level `layer` carried over from the extract loader's option. + layerValue: string | null; } interface UseSwap { @@ -90,6 +93,7 @@ interface UseSwap { parserProps: RuleProp[]; cssPublicPath: string | null; cssType: string | null; + layerValue: string | null; trivial: boolean; sourceMapOff: boolean; plan: ConfigPlan | null; @@ -238,6 +242,12 @@ class CssMigration { this.collectStyleAttributes(value, findings); continue; } + if (loaderName === EXTRACT_LOADER_NAME && name === "layer" && value) { + // Same semantics as the native rule-level `layer` (experiments.layers). + if (findPair(ruleObject, "layer")) findings.lost.push(`${EXTRACT_LOADER_NAME}.layer`); + else findings.layerValue ??= value.text(); + continue; + } if (loaderName === EXTRACT_LOADER_NAME && name === "emit" && value) { // `emit: false` (SSR) maps to the native exports-only generator. if (value.kind() === "false") { @@ -389,6 +399,14 @@ class CssMigration { plan.outputProps.push(prop); } } + if (findings.layerValue !== null) { + // Rule-level `layer` needs experiments.layers on the enclosing config. + if (plan) plan.needsLayers = true; + else { + findings.layerValue = null; + findings.lost.push(`${EXTRACT_LOADER_NAME}.layer`); + } + } } private hasIssuerRule(arrayNode: SgNode | null, issuerText: string): boolean { @@ -455,7 +473,8 @@ class CssMigration { entry.generatorProps.length > 0 || entry.parserProps.length > 0 || entry.cssPublicPath !== null || - entry.cssType !== null; + entry.cssType !== null || + entry.layerValue !== null; if (survives) swaps.push(entry); else removed.push(entry.ruleObject); } @@ -513,6 +532,7 @@ class CssMigration { cssPublicPath: null, cssType: null, outputProps: [], + layerValue: null, }; for (const element of elements) { this.collectLoaderOptionFindings(element, ruleObject, findings); @@ -546,6 +566,7 @@ class CssMigration { parserProps: dedupeProps(findings.parserProps), cssPublicPath: findings.cssPublicPath, cssType: findings.cssType, + layerValue: findings.layerValue, trivial, sourceMapOff: findings.cssSourceMapOff, plan, @@ -579,6 +600,7 @@ class CssMigration { cssPublicPath: null, cssType: null, outputProps: [], + layerValue: null, }; this.collectLoaderOptionFindings(ruleObject, ruleObject, findings); const config = findConfigObjectFor(pair); @@ -610,6 +632,7 @@ class CssMigration { parserProps: dedupeProps(findings.parserProps), cssPublicPath: findings.cssPublicPath, cssType: findings.cssType, + layerValue: findings.layerValue, trivial, sourceMapOff: findings.cssSourceMapOff, plan, @@ -673,6 +696,7 @@ class CssMigration { replacement += `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}`; } replacement += `type: "${swap.cssType ?? "css/auto"}"`; + if (swap.layerValue !== null) replacement += `${separator}layer: ${swap.layerValue}`; for (const [key, props] of [ ["generator", swap.generatorProps], ["parser", swap.parserProps], @@ -818,6 +842,7 @@ class CssMigration { plan = { config, needsExperimentsCss: false, + needsLayers: false, cssLoaderRules: 0, sourceMapOffRules: 0, outputProps: [], @@ -830,8 +855,11 @@ class CssMigration { private planConfigInsertions(): void { for (const plan of this.configPlans.values()) { const topProperties: ((indent: string, unit: string) => string)[] = []; - if (plan.needsExperimentsCss) { - this.planObjectProps(plan.config, "experiments", [{ name: "css", valueText: "true" }], topProperties); + const experimentProps: RuleProp[] = []; + if (plan.needsExperimentsCss) experimentProps.push({ name: "css", valueText: "true" }); + if (plan.needsLayers) experimentProps.push({ name: "layers", valueText: "true" }); + if (experimentProps.length) { + this.planObjectProps(plan.config, "experiments", experimentProps, topProperties); } if (plan.outputProps.length) { this.planObjectProps(plan.config, "output", plan.outputProps, topProperties); diff --git a/codemods/css-plugins-to-native-css/tests/layer-option/expected.js b/codemods/css-plugins-to-native-css/tests/layer-option/expected.js new file mode 100644 index 0000000..00f5f15 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/layer-option/expected.js @@ -0,0 +1,16 @@ +module.exports = { + experiments: { + css: true, + layers: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + layer: "styles", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/layer-option/input.js b/codemods/css-plugins-to-native-css/tests/layer-option/input.js new file mode 100644 index 0000000..e4e3904 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/layer-option/input.js @@ -0,0 +1,17 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + experiments: { + layers: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [{ loader: MiniCssExtractPlugin.loader, options: { layer: "styles" } }, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +};