@omnimod/plugin-redux-to-toolkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 salnika
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @omnimod/plugin-redux-to-toolkit
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40omnimod%2Fplugin-redux-to-toolkit?label=npm)](https://www.npmjs.com/package/@omnimod/plugin-redux-to-toolkit)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/Salnika/omnimod/ci.yml?branch=master&label=ci)](https://github.com/Salnika/omnimod/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/github/license/Salnika/omnimod)](../../LICENSE)
6
+
7
+ omnimod plugin that migrates legacy Redux store setup to Redux Toolkit.
8
+
9
+ ## Use With The CLI
10
+
11
+ ```bash
12
+ omnimod run redux-to-toolkit "src/**/*.{ts,tsx,js,jsx}"
13
+ omnimod run redux-to-toolkit "src/**/*.{ts,tsx,js,jsx}" --write
14
+ ```
15
+
16
+ ## Use As A Library
17
+
18
+ ```ts
19
+ import { reduxToToolkit } from "@omnimod/plugin-redux-to-toolkit";
20
+ ```
21
+
22
+ ## Notes
23
+
24
+ The plugin rewrites supported `createStore` usage to `configureStore` and updates
25
+ imports. Reducer rewrites, middleware-specific behavior, and connected component
26
+ patterns may need manual follow-up.
@@ -0,0 +1,13 @@
1
+ //#region src/plugin.d.ts
2
+ type ReduxToToolkitOptions = Record<string, unknown>;
3
+ /**
4
+ * Migrate legacy Redux to Redux Toolkit. Rewrites `redux` imports to
5
+ * `@reduxjs/toolkit` (re-exported helpers) and `createStore`/`legacy_createStore`
6
+ * to `configureStore`, rewriting `createStore(rootReducer[, preloadedState])`
7
+ * call sites into `configureStore({ reducer, preloadedState })`. Semantic
8
+ * migrations (switch reducers, `connect`, action creators, saga/thunk) are left
9
+ * untouched with a `warn` diagnostic and a `// TODO(omnimod)` breadcrumb.
10
+ */
11
+ declare const reduxToToolkit: import("@omnimod/core").Plugin<ReduxToToolkitOptions, unknown>;
12
+ //#endregion
13
+ export { type ReduxToToolkitOptions, reduxToToolkit as default, reduxToToolkit };
package/dist/index.mjs ADDED
@@ -0,0 +1,246 @@
1
+ import { definePlugin, walk } from "@omnimod/core";
2
+ import { cast } from "@omnimod/plugin-utils";
3
+ //#region src/plugin.ts
4
+ const RTK_MODULE = "@reduxjs/toolkit";
5
+ const REDUX_MODULE = "redux";
6
+ const RTK_REEXPORTED = /* @__PURE__ */ new Set([
7
+ "combineReducers",
8
+ "compose",
9
+ "bindActionCreators",
10
+ "applyMiddleware"
11
+ ]);
12
+ const STORE_CREATORS = /* @__PURE__ */ new Set(["createStore", "legacy_createStore"]);
13
+ /**
14
+ * Migrate legacy Redux to Redux Toolkit. Rewrites `redux` imports to
15
+ * `@reduxjs/toolkit` (re-exported helpers) and `createStore`/`legacy_createStore`
16
+ * to `configureStore`, rewriting `createStore(rootReducer[, preloadedState])`
17
+ * call sites into `configureStore({ reducer, preloadedState })`. Semantic
18
+ * migrations (switch reducers, `connect`, action creators, saga/thunk) are left
19
+ * untouched with a `warn` diagnostic and a `// TODO(omnimod)` breadcrumb.
20
+ */
21
+ const reduxToToolkit = definePlugin({
22
+ name: "redux-to-toolkit",
23
+ description: "Migrate legacy Redux to Redux Toolkit.",
24
+ include: ["**/*.{ts,tsx,js,jsx,mts,cts}"],
25
+ transform(file) {
26
+ const reduxImport = findReduxImport(file);
27
+ const storeCreatorLocals = /* @__PURE__ */ new Set();
28
+ if (reduxImport) {
29
+ for (const [imported, local] of reduxImport.named) if (STORE_CREATORS.has(imported)) storeCreatorLocals.add(local);
30
+ rewriteReduxImport(file, reduxImport);
31
+ }
32
+ if (storeCreatorLocals.size > 0) rewriteStoreCalls(file, storeCreatorLocals);
33
+ reportSemanticFollowups(file);
34
+ }
35
+ });
36
+ /** Find the first `import ... from "redux"` declaration in the file. */
37
+ function findReduxImport(file) {
38
+ for (const stmt of file.program.body) {
39
+ if (stmt.type !== "ImportDeclaration") continue;
40
+ const imp = cast(stmt);
41
+ if (imp.source.value !== REDUX_MODULE) continue;
42
+ const named = /* @__PURE__ */ new Map();
43
+ let hasNonNamed = false;
44
+ for (const spec of imp.specifiers) if (spec.type === "ImportSpecifier") {
45
+ const s = cast(spec);
46
+ const imported = s.imported.type === "Identifier" ? cast(s.imported).name : null;
47
+ if (imported === null) {
48
+ hasNonNamed = true;
49
+ continue;
50
+ }
51
+ named.set(imported, s.local.name);
52
+ } else hasNonNamed = true;
53
+ return {
54
+ node: imp,
55
+ named,
56
+ hasNonNamed
57
+ };
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Rewrite the `redux` import to `@reduxjs/toolkit`. Every named specifier we care
63
+ * about (re-exported helpers + store creators) is available from RTK, so in the
64
+ * common all-named case we just retarget the source and swap store-creator names.
65
+ * If any specifier is unknown (some other redux export) or the declaration has a
66
+ * default/namespace binding, we split: known names move to a new RTK import and
67
+ * the original `redux` import keeps the rest.
68
+ */
69
+ function rewriteReduxImport(file, reduxImport) {
70
+ const { node, named, hasNonNamed } = reduxImport;
71
+ const knownNames = [];
72
+ const unknownNames = [];
73
+ for (const imported of named.keys()) if (RTK_REEXPORTED.has(imported) || STORE_CREATORS.has(imported)) knownNames.push(imported);
74
+ else unknownNames.push(imported);
75
+ if (knownNames.length === 0) return;
76
+ if (!hasNonNamed && unknownNames.length === 0) {
77
+ for (const spec of node.specifiers) {
78
+ if (spec.type !== "ImportSpecifier") continue;
79
+ const s = cast(spec);
80
+ if (s.imported.type !== "Identifier") continue;
81
+ const importedName = cast(s.imported).name;
82
+ if (STORE_CREATORS.has(importedName)) file.magic.update(s.start, s.end, "configureStore");
83
+ }
84
+ file.magic.update(node.source.start, node.source.end, JSON.stringify(RTK_MODULE));
85
+ return;
86
+ }
87
+ const rtkSpecifiers = [];
88
+ for (const imported of knownNames) {
89
+ if (STORE_CREATORS.has(imported)) {
90
+ rtkSpecifiers.push("configureStore");
91
+ continue;
92
+ }
93
+ const local = named.get(imported) ?? imported;
94
+ rtkSpecifiers.push(local === imported ? imported : `${imported} as ${local}`);
95
+ }
96
+ const rtkImport = `import { ${[...new Set(rtkSpecifiers)].join(", ")} } from ${JSON.stringify(RTK_MODULE)};`;
97
+ removeKnownSpecifiers(file, node, knownNames);
98
+ file.magic.prependLeft(node.start, `${rtkImport}\n`);
99
+ }
100
+ /**
101
+ * Remove the given imported names from an import declaration's specifier list,
102
+ * adjusting the surrounding commas so the remaining specifiers stay well-formed.
103
+ */
104
+ function removeKnownSpecifiers(file, node, removeImported) {
105
+ const toRemove = new Set(removeImported);
106
+ const specs = node.specifiers;
107
+ for (let i = 0; i < specs.length; i++) {
108
+ const spec = specs[i];
109
+ if (!spec || spec.type !== "ImportSpecifier") continue;
110
+ const s = cast(spec);
111
+ if (s.imported.type !== "Identifier") continue;
112
+ if (!toRemove.has(cast(s.imported).name)) continue;
113
+ const prev = specs[i - 1];
114
+ const next = specs[i + 1];
115
+ if (next) file.magic.remove(spec.start, next.start);
116
+ else if (prev) file.magic.remove(prev.end, spec.end);
117
+ else file.magic.remove(spec.start, spec.end);
118
+ }
119
+ }
120
+ /** Rewrite `createStore(reducer[, preloadedState][, enhancer])` call sites. */
121
+ function rewriteStoreCalls(file, storeCreatorLocals) {
122
+ walk(file.program, (node) => {
123
+ if (node.type !== "CallExpression") return;
124
+ const call = cast(node);
125
+ if (call.callee.type !== "Identifier") return;
126
+ if (!storeCreatorLocals.has(cast(call.callee).name)) return;
127
+ const args = call.arguments;
128
+ const reducerArg = args[0];
129
+ if (!reducerArg) {
130
+ file.report({
131
+ message: "`createStore()` called without a reducer; convert to `configureStore({ reducer })` manually.",
132
+ severity: "warn"
133
+ });
134
+ return;
135
+ }
136
+ const reducerText = file.source.slice(reducerArg.start, reducerArg.end);
137
+ const secondArg = args[1];
138
+ const thirdArg = args[2];
139
+ const configParts = [`reducer: ${reducerText}`];
140
+ let enhancerText = null;
141
+ for (const extra of [secondArg, thirdArg]) {
142
+ if (!extra) continue;
143
+ if (isEnhancerLike(extra, file)) enhancerText = file.source.slice(extra.start, extra.end);
144
+ else {
145
+ const preloadedText = file.source.slice(extra.start, extra.end);
146
+ configParts.push(`preloadedState: ${preloadedText}`);
147
+ }
148
+ }
149
+ const objectLiteral = `configureStore({ ${configParts.join(", ")} })`;
150
+ file.magic.update(call.start, call.end, objectLiteral);
151
+ if (enhancerText !== null) {
152
+ file.report({
153
+ message: "Middleware/enhancers passed to `createStore` must be moved to `configureStore`'s `middleware`/`enhancers` option. Left the original as a TODO.",
154
+ severity: "warn"
155
+ });
156
+ file.magic.appendLeft(call.end, ` /* TODO(omnimod): move to configureStore middleware/enhancers: ${enhancerText} */`);
157
+ }
158
+ });
159
+ }
160
+ /** Heuristic: is this argument an enhancer (applyMiddleware/compose call) vs preloadedState? */
161
+ function isEnhancerLike(arg, file) {
162
+ if (arg.type !== "CallExpression") return false;
163
+ const calleeName = calleeRootName(cast(arg).callee);
164
+ if (calleeName === "applyMiddleware" || calleeName === "compose") return true;
165
+ const text = file.source.slice(arg.start, arg.end);
166
+ return /applyMiddleware|compose|enhancer|middleware/i.test(text);
167
+ }
168
+ /** Root identifier name of a (possibly member/call) callee, else null. */
169
+ function calleeRootName(node) {
170
+ if (node.type === "Identifier") return cast(node).name;
171
+ if (node.type === "MemberExpression") return calleeRootName(cast(node).object);
172
+ if (node.type === "CallExpression") return calleeRootName(cast(node).callee);
173
+ return null;
174
+ }
175
+ /**
176
+ * Report (but never rewrite) the semantic migrations that need human judgement:
177
+ * switch-based reducers, `connect(...)`, and saga/thunk middleware imports.
178
+ */
179
+ function reportSemanticFollowups(file) {
180
+ let reportedSwitchReducer = false;
181
+ let reportedConnect = false;
182
+ walk(file.program, (node) => {
183
+ if (!reportedSwitchReducer && isSwitchReducer(node)) {
184
+ reportedSwitchReducer = true;
185
+ file.report({
186
+ message: "Switch-statement reducer detected: convert it to `createSlice` (reducers map + generated action creators).",
187
+ severity: "warn"
188
+ });
189
+ file.magic.appendLeft(node.start, "// TODO(omnimod): convert this switch reducer to createSlice.\n");
190
+ }
191
+ if (!reportedConnect && isConnectCall(node)) {
192
+ reportedConnect = true;
193
+ file.report({
194
+ message: "`connect()` detected: prefer the `useSelector`/`useDispatch` hooks from react-redux.",
195
+ severity: "warn"
196
+ });
197
+ }
198
+ });
199
+ for (const stmt of file.program.body) {
200
+ if (stmt.type !== "ImportDeclaration") continue;
201
+ const src = cast(stmt).source.value;
202
+ if (src === "redux-saga" || src.startsWith("redux-saga/") || src === "redux-thunk") file.report({
203
+ message: `\`${src}\` middleware detected: RTK ships \`createAsyncThunk\`/listener middleware; migrate async logic accordingly.`,
204
+ severity: "info"
205
+ });
206
+ }
207
+ }
208
+ /**
209
+ * True for a function whose body is (or immediately contains) a `switch` on an
210
+ * `action.type`-shaped discriminant and whose first param defaults to an initial
211
+ * state — the classic hand-written reducer shape.
212
+ */
213
+ function isSwitchReducer(node) {
214
+ let params;
215
+ let body;
216
+ if (node.type === "ArrowFunctionExpression") {
217
+ const fn = cast(node);
218
+ params = fn.params;
219
+ body = fn.body;
220
+ } else if (node.type === "FunctionDeclaration") {
221
+ const fn = cast(node);
222
+ params = fn.params;
223
+ body = fn.body;
224
+ } else if (node.type === "FunctionExpression") {
225
+ const fn = cast(node);
226
+ params = fn.params;
227
+ body = fn.body;
228
+ } else return false;
229
+ if (params.length < 2) return false;
230
+ if (!(params[0]?.type === "AssignmentPattern")) return false;
231
+ if (!body || body.type !== "BlockStatement") return false;
232
+ return (body.body ?? []).some((stmt) => stmt.type === "SwitchStatement");
233
+ }
234
+ /** True for `connect(...)(Component)` or `connect(...)` from react-redux. */
235
+ function isConnectCall(node) {
236
+ if (node.type !== "CallExpression") return false;
237
+ const inner = cast(node).callee;
238
+ if (inner.type === "Identifier" && cast(inner).name === "connect") return true;
239
+ if (inner.type === "CallExpression") {
240
+ const innerCall = cast(inner);
241
+ if (innerCall.callee.type === "Identifier" && cast(innerCall.callee).name === "connect") return true;
242
+ }
243
+ return false;
244
+ }
245
+ //#endregion
246
+ export { reduxToToolkit as default, reduxToToolkit };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@omnimod/plugin-redux-to-toolkit",
3
+ "version": "0.1.0",
4
+ "description": "omnimod plugin: migrate legacy Redux to Redux Toolkit.",
5
+ "keywords": [
6
+ "codemod",
7
+ "migration",
8
+ "omnimod-plugin",
9
+ "redux",
10
+ "redux-toolkit"
11
+ ],
12
+ "homepage": "https://salnika.github.io/omnimod/",
13
+ "bugs": "https://github.com/salnika/omnimod/issues",
14
+ "license": "MIT",
15
+ "author": "salnika",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/salnika/omnimod.git",
19
+ "directory": "packages/plugin-redux-to-toolkit"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./dist/index.mjs",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "dependencies": {
33
+ "@omnimod/plugin-utils": "0.1.0",
34
+ "@omnimod/core": "0.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^25.6.2",
38
+ "@typescript/native-preview": "7.0.0-dev.20260509.2",
39
+ "typescript": "^6.0.3",
40
+ "vite": "npm:@voidzero-dev/vite-plus-core@0.2.2",
41
+ "vite-plus": "0.2.2"
42
+ },
43
+ "scripts": {
44
+ "build": "vp pack",
45
+ "dev": "vp pack --watch",
46
+ "test": "vp test",
47
+ "check": "vp check"
48
+ }
49
+ }