@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/LICENSE +674 -0
- package/README.md +56 -31
- package/bin/metamorph.js +0 -0
- package/dist/NextMigrationHints-BGO2W3K4.js +32 -0
- package/dist/chunk-H2LZEPNI.js +352 -0
- package/dist/{chunk-OXXEISZT.js → chunk-L7B6KUCW.js} +88 -39
- package/dist/index.js +529 -488
- package/dist/public/assets/index-C0AxAuiv.css +1 -0
- package/dist/public/assets/index-DtMWFmXl.js +63 -0
- package/dist/public/icons/angular.svg +1 -1
- package/dist/public/icons/expressjs.svg +1 -1
- package/dist/public/icons/fastify.svg +1 -1
- package/dist/public/icons/hapi.svg +1 -14
- package/dist/public/icons/nestjs.svg +1 -1
- package/dist/public/icons/nextjs.svg +1 -1
- package/dist/public/icons/react.svg +1 -1
- package/dist/public/icons/svelte.svg +1 -21
- package/dist/public/icons/vue.svg +1 -1
- package/dist/public/icons.svg +1 -24
- package/dist/public/index.html +2 -2
- package/dist/{src-2UXWWNPF.js → src-JHOGUCRS.js} +7 -1
- package/package.json +17 -4
- package/dist/public/assets/index-DDJRaEaa.css +0 -1
- package/dist/public/assets/index-PNJ1umlc.js +0 -63
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
|
-
|
|
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
|
-
##
|
|
21
|
+
## Features
|
|
22
22
|
|
|
23
|
-
- **Multi-Agent Concurrency
|
|
24
|
-
- **Zero Risk
|
|
25
|
-
- **Visual Dashboard
|
|
26
|
-
- **
|
|
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
|
-
##
|
|
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
|
-
|
|
37
|
+
### Prerequisites
|
|
37
38
|
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
57
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
###
|
|
70
|
-
|
|
79
|
+
### 4. Managing Migrations
|
|
80
|
+
|
|
81
|
+
Discard a run without applying it:
|
|
82
|
+
|
|
71
83
|
```bash
|
|
72
84
|
metamorph rollback <runId>
|
|
73
85
|
```
|
|
74
|
-
|
|
86
|
+
|
|
87
|
+
View migration history:
|
|
88
|
+
|
|
75
89
|
```bash
|
|
76
90
|
metamorph list
|
|
77
91
|
```
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
View all available commands and options:
|
|
90
108
|
|
|
91
|
-
|
|
109
|
+
```bash
|
|
110
|
+
metamorph --help
|
|
111
|
+
```
|
|
92
112
|
|
|
93
|
-
|
|
113
|
+
## Supported Migrations
|
|
94
114
|
|
|
95
|
-
|
|
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
|
-
|
|
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
|
+
};
|