@nikelyh/metamorph 2.1.0 → 2.1.1

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/README.md CHANGED
@@ -16,16 +16,17 @@
16
16
 
17
17
  Metamorph is a CLI tool powered by [Mozaik](https://github.com/jigjoy-ai/mozaik) that automates complex architectural shifts and framework migrations in your codebase. Instead of doing it manually, Metamorph spawns a swarm of specialized AI agents that safely refactor your code in an isolated "Shadow Workspace".
18
18
 
19
- 📚 **[Read the Full Documentation](https://metamorph.nikelyh.tech/docs)**
19
+ **[Read the Full Documentation](https://metamorph.nikelyh.tech/docs)** | **[System Architecture](https://github.com/yohanvillarp/metamorph/blob/main/docs/architecture.md)**
20
20
 
21
- ## 🚀 Features
21
+ ## Features
22
22
 
23
- - **Multi-Agent Concurrency**: Utilizes Mapper, Worker, Reviewer, and PackageManager agents to migrate code.
24
- - **Zero Risk**: All migrations happen in a `.metamorph/shadow` workspace. Your original code is completely untouched until you explicitly approve and integrate the changes.
25
- - **Visual Dashboard**: Watch your agents work in real-time through the built-in local dashboard UI.
26
- - **Extensive Framework Support**: Easily migrate between popular frameworks automatically.
23
+ - **Multi-Agent Concurrency** -- Utilizes Mapper, Worker, Reviewer, Integration, and Coordinator agents to migrate code in parallel.
24
+ - **Zero Risk** -- All migrations happen in a `.metamorph/shadow` workspace. Your original code is completely untouched until you explicitly approve and apply the changes.
25
+ - **Visual Dashboard** -- Watch your agents work in real-time through the built-in local dashboard UI.
26
+ - **Shadow Build Verification** -- Every migration is validated with `npm install` and `npm run build` inside the shadow workspace before completion.
27
+ - **Extensive Framework Support** -- Easily migrate between popular frontend and backend frameworks.
27
28
 
28
- ## 📦 Installation
29
+ ## Installation
29
30
 
30
31
  Install Metamorph globally via NPM:
31
32
 
@@ -33,66 +34,90 @@ Install Metamorph globally via NPM:
33
34
  npm install -g @nikelyh/metamorph
34
35
  ```
35
36
 
36
- ## 🛠️ Typical Workflow
37
+ ### Prerequisites
37
38
 
38
- You can run the entire migration lifecycle using just your terminal.
39
+ - Node.js >= 20
40
+ - An LLM provider API key (OpenAI, Anthropic, or compatible). Set it as an environment variable:
41
+ ```bash
42
+ export OPENAI_API_KEY=sk-...
43
+ ```
39
44
 
40
- ### 1. Help & Options
41
- To see all available commands and options at any time, run:
42
- ```bash
43
- metamorph --help
44
- ```
45
+ ## Typical Workflow
46
+
47
+ ### 1. Start a Migration
45
48
 
46
- ### 2. Start a Migration
47
49
  Navigate to the root of the project you want to migrate and run:
50
+
48
51
  ```bash
49
52
  metamorph run
50
53
  ```
51
- Metamorph will automatically detect your current stack (e.g. Express, React) and prompt you to select the target framework. Alternatively, you can bypass the interactive prompt by passing the source explicitly:
54
+
55
+ Metamorph automatically detects your current stack (e.g., Express, React) and prompts you to select the target framework. You can bypass the interactive prompt by passing the source explicitly:
56
+
52
57
  ```bash
53
58
  metamorph run --from express
54
59
  ```
55
60
 
56
- ### 3. Monitor the Agents (Dashboard UI)
57
- Once a migration is running, the AI swarm begins working in the background. To see their real-time progress, open a **new terminal tab** in the same folder and run:
61
+ ### 2. Monitor the Agents (Dashboard UI)
62
+
63
+ Once a migration is running, the AI swarm begins working in the background. To see their real-time progress, open a new terminal in the same directory and run:
64
+
58
65
  ```bash
59
66
  metamorph ui
60
67
  ```
61
- This will start the internal dashboard server and open a Neo-Brutalist interface in your default browser. You can specify a port with `--port 8080`.
62
68
 
63
- ### 4. Review and Apply
64
- After the AI finishes the migration in the isolated Shadow Workspace, you will receive a `runId`. You can apply the completed migration to your actual repository (which automatically creates a new git branch for safety):
69
+ This starts the internal dashboard server and opens a monitoring interface in your default browser. You can specify a port with `--port 8080`.
70
+
71
+ ### 3. Review and Apply
72
+
73
+ After the AI finishes the migration in the isolated Shadow Workspace, you will receive a `runId`. Apply the completed migration to your actual repository (which automatically creates a new Git branch for safety):
74
+
65
75
  ```bash
66
76
  metamorph apply <runId> .
67
77
  ```
68
78
 
69
- ### 5. Managing Migrations
70
- If you want to discard a run without applying it:
79
+ ### 4. Managing Migrations
80
+
81
+ Discard a run without applying it:
82
+
71
83
  ```bash
72
84
  metamorph rollback <runId>
73
85
  ```
74
- To see a history of all migrations:
86
+
87
+ View migration history:
88
+
75
89
  ```bash
76
90
  metamorph list
77
91
  ```
78
- To clear the local database and reset all events:
92
+
93
+ Clear the local database and reset all events:
94
+
79
95
  ```bash
80
96
  metamorph reset
81
97
  ```
82
98
 
83
99
  ### Utility Commands
84
- To test the technology detection on your repository without starting a migration:
100
+
101
+ Test the technology detection on your repository without starting a migration:
102
+
85
103
  ```bash
86
104
  metamorph detect .
87
105
  ```
88
106
 
89
- ## 🔄 Supported Migrations
107
+ View all available commands and options:
90
108
 
91
- As Metamorph's AI capabilities are constantly expanding, the list of supported architectural shifts is updated frequently.
109
+ ```bash
110
+ metamorph --help
111
+ ```
92
112
 
93
- To see the complete, up-to-date list of all supported frontend and backend framework migrations, please visit our official documentation:
113
+ ## Supported Migrations
94
114
 
95
- 👉 **[View Supported Migrations](https://metamorph.nikelyh.tech/docs/migrations)**
115
+ The list of supported architectural shifts is updated frequently as new migration catalogs are added.
116
+
117
+ See the complete, up-to-date list of all supported frontend and backend framework migrations in the official documentation:
118
+
119
+ **[View Supported Migrations](https://metamorph.nikelyh.tech/docs/migrations)**
96
120
 
97
121
  ---
98
- *Built with ❤️ using Mozaik.*
122
+
123
+ *Built with [Mozaik](https://github.com/jigjoy-ai/mozaik).*
package/bin/metamorph.js CHANGED
File without changes
@@ -0,0 +1,32 @@
1
+ import {
2
+ analyzeCallbackPropMismatches,
3
+ analyzeReactToNextComposition,
4
+ analyzeReactToNextStructure,
5
+ analyzeUnresolvedLocalImports,
6
+ findShadowRoot,
7
+ hasNextAppRoutes,
8
+ isFsdPageComponent,
9
+ isHollowView,
10
+ isSpaPagesTreeFile,
11
+ isSpaRootComponent,
12
+ isStubAppRouterPage,
13
+ listAppRouterFiles,
14
+ nextMigrationFileHint,
15
+ unresolvedLocalImports
16
+ } from "./chunk-H2LZEPNI.js";
17
+ export {
18
+ analyzeCallbackPropMismatches,
19
+ analyzeReactToNextComposition,
20
+ analyzeReactToNextStructure,
21
+ analyzeUnresolvedLocalImports,
22
+ findShadowRoot,
23
+ hasNextAppRoutes,
24
+ isFsdPageComponent,
25
+ isHollowView,
26
+ isSpaPagesTreeFile,
27
+ isSpaRootComponent,
28
+ isStubAppRouterPage,
29
+ listAppRouterFiles,
30
+ nextMigrationFileHint,
31
+ unresolvedLocalImports
32
+ };
@@ -0,0 +1,352 @@
1
+ // ../application/src/utils/NextMigrationHints.ts
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ function findShadowRoot(filePath) {
5
+ const normalized = filePath.replace(/\\/g, "/");
6
+ const marker = "/.metamorph/shadow/";
7
+ const index = normalized.indexOf(marker);
8
+ if (index === -1) return null;
9
+ const after = normalized.slice(index + marker.length);
10
+ const runId = after.split("/")[0];
11
+ if (!runId) return null;
12
+ return filePath.slice(0, index + marker.length + runId.length);
13
+ }
14
+ function isFsdPageComponent(filePath) {
15
+ return /[/\\]src[/\\]pages[/\\][^/\\]+[/\\]ui[/\\]/i.test(filePath);
16
+ }
17
+ function isSpaRootComponent(filePath) {
18
+ return /[/\\](App|main|index)\.(t|j)sx?$/i.test(filePath) && !/[/\\]src[/\\]app[/\\]/i.test(filePath) && !isFsdPageComponent(filePath);
19
+ }
20
+ function hasNextAppRoutes(shadowRoot) {
21
+ const appDir = path.join(shadowRoot, "src", "app");
22
+ if (!fs.existsSync(appDir)) return false;
23
+ const stack = [appDir];
24
+ while (stack.length > 0) {
25
+ const dir = stack.pop();
26
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
27
+ if (entry.isDirectory()) {
28
+ stack.push(path.join(dir, entry.name));
29
+ } else if (entry.name === "page.tsx" || entry.name === "page.jsx" || entry.name === "page.js") {
30
+ return true;
31
+ }
32
+ }
33
+ }
34
+ return false;
35
+ }
36
+ function nextMigrationFileHint(filePath, target) {
37
+ if (target !== "next") return "";
38
+ if (isFsdPageComponent(filePath) || isSpaPagesTreeFile(filePath)) {
39
+ return `
40
+ This file sits under src/pages. In Next.js that directory is the Pages Router.
41
+ Move this file out of src/pages (keep the component and its business logic) into a folder Next does not treat as routes.
42
+ Then add or update src/app/<route>/page.tsx so the App Router imports that screen for the former URL.
43
+ The home route ("/") is src/app/page.tsx and must render the former "/" screen \u2014 not an empty shell, null, a TODO, or a deleted App.tsx.
44
+ Delete the old src/pages copy after the screen exists elsewhere.
45
+ `;
46
+ }
47
+ if (isSpaRootComponent(filePath)) {
48
+ return `
49
+ This is a SPA root/entry file (App.tsx / main.tsx / index.tsx).
50
+ Read it and map each route to src/app/<path>/page.tsx that imports the actual screen component (after moving anything that lived under src/pages).
51
+ src/app/page.tsx must render the home screen. Never import App from the new page (App is being removed).
52
+ Never write a stub that the root component "could not be resolved". After App Router files exist with real UI, delete this legacy root file.
53
+ `;
54
+ }
55
+ if (/[/\\]src[/\\]app[/\\].*page\.(t|j)sx$/i.test(filePath)) {
56
+ return `
57
+ This is a Next App Router page. Import the existing screen component and return it.
58
+ Do not paste that screen's JSX here. Do not rename its public props at the wrapper.
59
+ Do not hollow out the module you imported from.
60
+ `;
61
+ }
62
+ return "";
63
+ }
64
+ var NEXT_PAGES_RESERVED = /(?:^|[/\\])(_app|_document|_error|404|500)\.(t|j)sx?$/i;
65
+ function isSpaPagesTreeFile(filePath) {
66
+ const normalized = filePath.replace(/\\/g, "/");
67
+ if (!/\/src\/pages\//i.test(normalized)) return false;
68
+ if (/\/src\/pages\/api\//i.test(normalized)) return false;
69
+ if (NEXT_PAGES_RESERVED.test(normalized)) return false;
70
+ return true;
71
+ }
72
+ function listFilesRecursive(dir) {
73
+ if (!fs.existsSync(dir)) return [];
74
+ const out = [];
75
+ const stack = [dir];
76
+ while (stack.length > 0) {
77
+ const current = stack.pop();
78
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
79
+ const full = path.join(current, entry.name);
80
+ if (entry.isDirectory()) stack.push(full);
81
+ else if (/\.(t|j)sx?$/.test(entry.name)) out.push(full);
82
+ }
83
+ }
84
+ return out;
85
+ }
86
+ function findAppRouterHome(shadowRoot) {
87
+ const candidates = [
88
+ path.join(shadowRoot, "src", "app", "page.tsx"),
89
+ path.join(shadowRoot, "src", "app", "page.jsx"),
90
+ path.join(shadowRoot, "app", "page.tsx"),
91
+ path.join(shadowRoot, "app", "page.jsx")
92
+ ];
93
+ return candidates.find((file) => fs.existsSync(file)) || null;
94
+ }
95
+ function listAppRouterFiles(shadowRoot) {
96
+ const roots = [path.join(shadowRoot, "src", "app"), path.join(shadowRoot, "app")];
97
+ const names = /^(page|layout)\.(t|j)sx$/i;
98
+ return roots.flatMap((dir) => listFilesRecursive(dir).filter((file) => names.test(path.basename(file))));
99
+ }
100
+ function isHollowView(content) {
101
+ const withoutComments = content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
102
+ const hasFeatureImports = /from\s+['"][^'"]*\/(features|entities|widgets)\//.test(withoutComments);
103
+ const tinyTree = /<h1[^>]*>[^<]{0,40}<\/h1>/.test(withoutComments) && !hasFeatureImports;
104
+ const emptyMain = /<main>\s*<h1>/.test(withoutComments) && !hasFeatureImports;
105
+ return tinyTree || emptyMain;
106
+ }
107
+ function findViewPages(shadowRoot) {
108
+ const viewsDir = path.join(shadowRoot, "src", "views");
109
+ return listFilesRecursive(viewsDir).filter((file) => /(?:^|[/\\])(?:.+Page|page)\.(t|j)sx$/i.test(file));
110
+ }
111
+ function resolveLocalImport(fromFile, specifier) {
112
+ const dir = path.dirname(fromFile);
113
+ const resolved = specifier.startsWith("@/") ? path.join(findSrcRoot(fromFile), specifier.slice(2)) : path.resolve(dir, specifier);
114
+ const candidates = [
115
+ resolved,
116
+ `${resolved}.tsx`,
117
+ `${resolved}.jsx`,
118
+ `${resolved}.ts`,
119
+ `${resolved}.js`,
120
+ path.join(resolved, "index.tsx"),
121
+ path.join(resolved, "index.ts")
122
+ ];
123
+ return candidates.find((file) => {
124
+ try {
125
+ return fs.existsSync(file) && fs.statSync(file).isFile();
126
+ } catch {
127
+ return false;
128
+ }
129
+ }) || null;
130
+ }
131
+ function localTsxImports(fromFile, content) {
132
+ const out = [];
133
+ const re = /import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"](\.\.?\/[^'"]+|@\/[^'"]+)['"]/g;
134
+ let match;
135
+ while ((match = re.exec(content)) !== null) {
136
+ const absPath = resolveLocalImport(fromFile, match[3]);
137
+ if (!absPath) continue;
138
+ if (match[2]) out.push({ tag: match[2], absPath });
139
+ if (match[1]) {
140
+ for (const part of match[1].split(",")) {
141
+ const name = part.trim().split(/\s+as\s+/).pop()?.trim();
142
+ if (name) out.push({ tag: name, absPath });
143
+ }
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+ function unresolvedLocalImports(fromFile, content) {
149
+ const out = [];
150
+ const re = /from\s+['"](\.\.?\/[^'"]+|@\/[^'"]+)['"]/g;
151
+ let match;
152
+ while ((match = re.exec(content)) !== null) {
153
+ if (!resolveLocalImport(fromFile, match[1])) {
154
+ out.push(match[1]);
155
+ }
156
+ }
157
+ return out;
158
+ }
159
+ function analyzeUnresolvedLocalImports(shadowRoot) {
160
+ const issues = [];
161
+ const files = [
162
+ ...listFilesRecursive(path.join(shadowRoot, "src", "app")),
163
+ ...listFilesRecursive(path.join(shadowRoot, "app"))
164
+ ].filter((file) => /\.(t|j)sx$/.test(file));
165
+ for (const file of files) {
166
+ const content = fs.readFileSync(file, "utf-8");
167
+ const missing = unresolvedLocalImports(file, content);
168
+ if (missing.length === 0) continue;
169
+ issues.push({
170
+ filePath: file,
171
+ errors: [
172
+ `${path.relative(shadowRoot, file)} imports modules that are not on disk: ${missing.join(", ")}. Do not import a deleted App/main from App Router pages \u2014 import the surviving screen component instead.`
173
+ ]
174
+ });
175
+ }
176
+ return issues;
177
+ }
178
+ function findSrcRoot(filePath) {
179
+ const normalized = filePath.replace(/\\/g, "/");
180
+ const idx = normalized.lastIndexOf("/src/");
181
+ if (idx === -1) return path.dirname(filePath);
182
+ return filePath.slice(0, idx + 4);
183
+ }
184
+ function declaredCallbackProps(componentSource) {
185
+ const names = /* @__PURE__ */ new Set();
186
+ const iface = /interface\s+\w+\s*\{([^}]+)\}/g;
187
+ let block;
188
+ while ((block = iface.exec(componentSource)) !== null) {
189
+ const field = /(\b(?:on[A-Z]\w+|action[A-Z]\w+)\??)\s*:/g;
190
+ let f;
191
+ while ((f = field.exec(block[1])) !== null) names.add(f[1].replace("?", ""));
192
+ }
193
+ const dest = /export\s+(?:default\s+)?function\s+\w+\s*\(\s*\{\s*([^}]+)\}/;
194
+ const d = dest.exec(componentSource);
195
+ if (d) {
196
+ for (const part of d[1].split(",")) {
197
+ const name = part.trim().split(":")[0].trim();
198
+ if (/^(on[A-Z]|action[A-Z])/.test(name)) names.add(name);
199
+ }
200
+ }
201
+ return [...names];
202
+ }
203
+ function jsxCallbackProps(callerSource, tag) {
204
+ const names = [];
205
+ const tagRe = new RegExp(`<${tag}\\b([^>]*)\\/?>`, "g");
206
+ let match;
207
+ while ((match = tagRe.exec(callerSource)) !== null) {
208
+ const propRe = /\b((?:on|action)[A-Z]\w+)\s*=/g;
209
+ let p;
210
+ while ((p = propRe.exec(match[1])) !== null) names.push(p[1]);
211
+ }
212
+ return names;
213
+ }
214
+ function analyzeCallbackPropMismatches(shadowRoot) {
215
+ const issues = [];
216
+ const files = [
217
+ ...listFilesRecursive(path.join(shadowRoot, "src", "app")),
218
+ ...listFilesRecursive(path.join(shadowRoot, "app")),
219
+ ...listFilesRecursive(path.join(shadowRoot, "src", "views"))
220
+ ].filter((file) => /\.(t|j)sx$/.test(file));
221
+ for (const file of files) {
222
+ const content = fs.readFileSync(file, "utf-8");
223
+ for (const imp of localTsxImports(file, content)) {
224
+ let callee;
225
+ try {
226
+ callee = fs.readFileSync(imp.absPath, "utf-8");
227
+ } catch {
228
+ continue;
229
+ }
230
+ const expected = declaredCallbackProps(callee);
231
+ if (expected.length === 0) continue;
232
+ const passed = jsxCallbackProps(content, imp.tag);
233
+ const missing = expected.filter((name) => !passed.includes(name));
234
+ const extra = passed.filter((name) => !expected.includes(name));
235
+ if (missing.length === 0 && extra.length === 0) continue;
236
+ issues.push({
237
+ filePath: file,
238
+ errors: [
239
+ `<${imp.tag}> public callback props do not match ${path.relative(shadowRoot, imp.absPath)}. Expected: ${expected.join(", ") || "(none)"}. Passed: ${passed.join(", ") || "(none)"}. Do not rename props when wrapping a screen in a router file.`
240
+ ]
241
+ });
242
+ }
243
+ }
244
+ return issues;
245
+ }
246
+ function analyzeReactToNextComposition(shadowRoot) {
247
+ const issues = [];
248
+ const home = findAppRouterHome(shadowRoot);
249
+ if (!home) return issues;
250
+ const homeContent = fs.readFileSync(home, "utf-8");
251
+ const views = findViewPages(shadowRoot);
252
+ const hollowViews = views.filter((file) => isHollowView(fs.readFileSync(file, "utf-8")));
253
+ const homeImportsView = views.some((file) => {
254
+ const stem = path.basename(file).replace(/\.(t|j)sx$/, "");
255
+ return homeContent.includes(stem);
256
+ });
257
+ const homeAssemblesFeatures = /from\s+['"][^'"]*\/(features|entities|widgets)\//.test(homeContent);
258
+ if (views.length > 0 && !homeImportsView && homeAssemblesFeatures) {
259
+ issues.push({
260
+ filePath: home,
261
+ errors: [
262
+ "The App Router home reassembles the UI from lower-level modules instead of importing the existing route-level screen. Import that screen and return it. Renaming props at the wrapper causes runtime TypeError while next build still succeeds."
263
+ ]
264
+ });
265
+ }
266
+ for (const file of hollowViews) {
267
+ issues.push({
268
+ filePath: file,
269
+ errors: [
270
+ "This screen was hollowed out. Restore the original UI here. The App Router page should import this file, not replace it with a stub heading."
271
+ ]
272
+ });
273
+ }
274
+ return issues;
275
+ }
276
+ function isStubAppRouterPage(content) {
277
+ const hasLocalImport = /from\s+['"](\.\.?\/|@\/)/.test(content);
278
+ const emptyish = /return\s*\(\s*<main\s*\/>\s*\)|return\s+null\s*;?|return\s*\(\s*<>\s*<\/>\s*\)|TODO:|could not be resolved/i.test(content);
279
+ const onlyLayoutShell = /<main\s*\/>/.test(content) && !hasLocalImport;
280
+ return !hasLocalImport || emptyish || onlyLayoutShell;
281
+ }
282
+ function analyzeReactToNextStructure(shadowRoot) {
283
+ const issues = [];
284
+ const pagesDir = path.join(shadowRoot, "src", "pages");
285
+ if (fs.existsSync(pagesDir)) {
286
+ for (const file of listFilesRecursive(pagesDir)) {
287
+ if (!isSpaPagesTreeFile(file)) continue;
288
+ issues.push({
289
+ filePath: file,
290
+ errors: [
291
+ "This UI still lives under src/pages. Next.js will treat it as a Pages Router route. Move it to a folder Next does not reserve, then import it from src/app/**/page.tsx for the old URL. Remove the src/pages copy."
292
+ ]
293
+ });
294
+ }
295
+ }
296
+ const home = findAppRouterHome(shadowRoot);
297
+ if (!home) {
298
+ issues.push({
299
+ filePath: path.join(shadowRoot, "src", "app", "page.tsx"),
300
+ errors: [
301
+ 'Missing App Router home (src/app/page.tsx). Create it so it imports and renders the screen the SPA showed at "/".'
302
+ ]
303
+ });
304
+ return issues;
305
+ }
306
+ const homeContent = fs.readFileSync(home, "utf-8");
307
+ if (isStubAppRouterPage(homeContent)) {
308
+ issues.push({
309
+ filePath: home,
310
+ errors: [
311
+ "src/app/page.tsx does not render the application UI. Import the former home screen component (after moving any SPA UI out of src/pages if needed). Do not return an empty shell, null, a TODO, or a deleted App.tsx."
312
+ ]
313
+ });
314
+ }
315
+ issues.push(...analyzeUnresolvedLocalImports(shadowRoot));
316
+ issues.push(...analyzeReactToNextComposition(shadowRoot));
317
+ issues.push(...analyzeCallbackPropMismatches(shadowRoot));
318
+ const pkgPath = path.join(shadowRoot, "package.json");
319
+ if (fs.existsSync(pkgPath)) {
320
+ try {
321
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
322
+ const viteScripts = Object.entries(pkg.scripts || {}).filter(([, cmd]) => /\bvite\b/.test(cmd));
323
+ if (viteScripts.length > 0) {
324
+ issues.push({
325
+ filePath: pkgPath,
326
+ errors: [
327
+ `package.json still runs Vite in scripts (${viteScripts.map(([name]) => name).join(", ")}). After moving to Next, drop leftover preview/vite scripts.`
328
+ ]
329
+ });
330
+ }
331
+ } catch {
332
+ }
333
+ }
334
+ return issues;
335
+ }
336
+
337
+ export {
338
+ findShadowRoot,
339
+ isFsdPageComponent,
340
+ isSpaRootComponent,
341
+ hasNextAppRoutes,
342
+ nextMigrationFileHint,
343
+ isSpaPagesTreeFile,
344
+ listAppRouterFiles,
345
+ isHollowView,
346
+ unresolvedLocalImports,
347
+ analyzeUnresolvedLocalImports,
348
+ analyzeCallbackPropMismatches,
349
+ analyzeReactToNextComposition,
350
+ isStubAppRouterPage,
351
+ analyzeReactToNextStructure
352
+ };