@mandujs/mcp 0.29.0 → 0.31.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/package.json +3 -3
- package/src/resources/skills/loader.ts +218 -218
- package/src/resources/skills/mandu-deployment/rules/db-provider-supabase.md +300 -0
- package/src/server.ts +2 -1
- package/src/tools/ai-brief.ts +443 -443
- package/src/tools/ate-run.ts +13 -2
- package/src/tools/ate.ts +52 -3
- package/src/tools/brain.ts +37 -1
- package/src/tools/decisions.ts +270 -270
- package/src/tools/docs.ts +349 -0
- package/src/tools/extract-contract.ts +406 -406
- package/src/tools/guard.ts +56 -3
- package/src/tools/index.ts +4 -0
- package/src/tools/migrate-route-conventions.ts +345 -345
- package/src/tools/project.ts +128 -35
- package/src/tools/resource.ts +2 -1
- package/src/tools/rewrite-generated-barrel.ts +403 -403
- package/src/resources/skills/mandu-deployment/rules/deploy-platform-supabase.md +0 -323
|
@@ -1,345 +1,345 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MCP tool — `mandu.refactor.migrate_route_conventions`
|
|
3
|
-
*
|
|
4
|
-
* Detects Next.js-style inline patterns in user code and migrates them to
|
|
5
|
-
* Mandu's file-system route conventions:
|
|
6
|
-
*
|
|
7
|
-
* • Inline `<Suspense fallback={…}>…</Suspense>` → extract to `loading.tsx`
|
|
8
|
-
* • Inline `<ErrorBoundary fallback={…}>…</ErrorBoundary>` → `error.tsx`
|
|
9
|
-
* • Inline `if (!x) return <NotFound />` / `notFound()` call → `not-found.tsx`
|
|
10
|
-
*
|
|
11
|
-
* Scope:
|
|
12
|
-
* • We only act on files under the `app/` tree (Mandu's routes dir).
|
|
13
|
-
* • We never overwrite an existing convention file — if `loading.tsx`
|
|
14
|
-
* already exists, we report the route as already-migrated.
|
|
15
|
-
* • We do not parse TSX with a real parser — detection uses conservative
|
|
16
|
-
* regex markers. False positives are acceptable; false file writes are
|
|
17
|
-
* not, so we bail out of extraction on any ambiguity.
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
-
import { readdir } from "fs/promises";
|
|
22
|
-
import path from "path";
|
|
23
|
-
|
|
24
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
25
|
-
// Types
|
|
26
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
export type RouteConvention = "loading" | "error" | "not-found";
|
|
29
|
-
|
|
30
|
-
export interface MigrateRouteConventionsInput {
|
|
31
|
-
dryRun?: boolean;
|
|
32
|
-
routes?: string[];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface MigrateExtractionEntry {
|
|
36
|
-
route: string;
|
|
37
|
-
convention: RouteConvention;
|
|
38
|
-
extractedPath: string;
|
|
39
|
-
sourceFile: string;
|
|
40
|
-
note?: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface MigrateRouteConventionsResult {
|
|
44
|
-
routes: string[];
|
|
45
|
-
extracted: MigrateExtractionEntry[];
|
|
46
|
-
skipped: Array<{ route: string; reason: string }>;
|
|
47
|
-
dryRun: boolean;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
51
|
-
// Validation
|
|
52
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
53
|
-
|
|
54
|
-
function validateInput(
|
|
55
|
-
raw: Record<string, unknown>,
|
|
56
|
-
):
|
|
57
|
-
| { ok: true; value: { dryRun: boolean; routes?: string[] } }
|
|
58
|
-
| { ok: false; error: string; field: string; hint: string } {
|
|
59
|
-
const dryRun = raw.dryRun;
|
|
60
|
-
if (dryRun !== undefined && typeof dryRun !== "boolean") {
|
|
61
|
-
return {
|
|
62
|
-
ok: false,
|
|
63
|
-
error: "'dryRun' must be a boolean",
|
|
64
|
-
field: "dryRun",
|
|
65
|
-
hint: "Omit to default to true, pass false to actually write files",
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
const routes = raw.routes;
|
|
69
|
-
if (routes !== undefined) {
|
|
70
|
-
if (!Array.isArray(routes) || !routes.every((r) => typeof r === "string")) {
|
|
71
|
-
return {
|
|
72
|
-
ok: false,
|
|
73
|
-
error: "'routes' must be an array of strings",
|
|
74
|
-
field: "routes",
|
|
75
|
-
hint: "E.g. ['app/dashboard', 'app/users/[id]']",
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return {
|
|
80
|
-
ok: true,
|
|
81
|
-
value: {
|
|
82
|
-
dryRun: dryRun === undefined ? true : dryRun,
|
|
83
|
-
...(Array.isArray(routes) ? { routes: routes as string[] } : {}),
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
89
|
-
// Route discovery — a lightweight replacement for fs-scanner that keeps
|
|
90
|
-
// this tool self-contained. We look for `page.ts(x)` files under `app/`.
|
|
91
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
async function collectRoutes(
|
|
94
|
-
projectRoot: string,
|
|
95
|
-
filter?: string[],
|
|
96
|
-
): Promise<string[]> {
|
|
97
|
-
const appDir = path.join(projectRoot, "app");
|
|
98
|
-
const found: string[] = [];
|
|
99
|
-
await walkPages(appDir, found);
|
|
100
|
-
const rels = found.map((p) => path.relative(projectRoot, path.dirname(p)));
|
|
101
|
-
if (!filter || filter.length === 0) return rels.sort();
|
|
102
|
-
const set = new Set(filter.map((f) => f.replace(/\\/g, "/")));
|
|
103
|
-
return rels.filter((r) => set.has(r.replace(/\\/g, "/"))).sort();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function walkPages(dir: string, out: string[]): Promise<void> {
|
|
107
|
-
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
108
|
-
try {
|
|
109
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
110
|
-
} catch {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
for (const e of entries) {
|
|
114
|
-
const full = path.join(dir, e.name);
|
|
115
|
-
if (e.isDirectory()) {
|
|
116
|
-
if (e.name.startsWith("_") || e.name === "node_modules") continue;
|
|
117
|
-
await walkPages(full, out);
|
|
118
|
-
} else if (
|
|
119
|
-
e.isFile() &&
|
|
120
|
-
/^page\.(?:tsx|ts|jsx|js)$/.test(e.name)
|
|
121
|
-
) {
|
|
122
|
-
out.push(full);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
128
|
-
// Detection
|
|
129
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
130
|
-
|
|
131
|
-
export interface DetectionHit {
|
|
132
|
-
convention: RouteConvention;
|
|
133
|
-
fallbackSnippet: string;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Scan a single page source for inline Suspense / ErrorBoundary / NotFound
|
|
138
|
-
* patterns. Exported for regression tests.
|
|
139
|
-
*/
|
|
140
|
-
export function detectConventions(source: string): DetectionHit[] {
|
|
141
|
-
const hits: DetectionHit[] = [];
|
|
142
|
-
|
|
143
|
-
// `<Suspense fallback={<Loading />}>`
|
|
144
|
-
const suspense = /<Suspense\s+fallback\s*=\s*\{([^}]*)\}\s*>/.exec(source);
|
|
145
|
-
if (suspense) {
|
|
146
|
-
hits.push({
|
|
147
|
-
convention: "loading",
|
|
148
|
-
fallbackSnippet: suspense[1].trim(),
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// `<ErrorBoundary fallback={…}>`
|
|
153
|
-
const errBoundary = /<ErrorBoundary\s+fallback\s*=\s*\{([^}]*)\}\s*>/.exec(source);
|
|
154
|
-
if (errBoundary) {
|
|
155
|
-
hits.push({
|
|
156
|
-
convention: "error",
|
|
157
|
-
fallbackSnippet: errBoundary[1].trim(),
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// `if (…) return <NotFound />` — lenient single-line match
|
|
162
|
-
const inlineNotFound =
|
|
163
|
-
/return\s*<NotFound\s*\/?\s*>|notFound\s*\(\s*\)/.exec(source);
|
|
164
|
-
if (inlineNotFound) {
|
|
165
|
-
hits.push({
|
|
166
|
-
convention: "not-found",
|
|
167
|
-
fallbackSnippet: "<div>Not found</div>",
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return hits;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function conventionFilename(c: RouteConvention): string {
|
|
175
|
-
return `${c}.tsx`;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function renderConventionFile(
|
|
179
|
-
convention: RouteConvention,
|
|
180
|
-
fallback: string,
|
|
181
|
-
): string {
|
|
182
|
-
const header =
|
|
183
|
-
`/**\n` +
|
|
184
|
-
` * Auto-generated by mandu.refactor.migrate_route_conventions.\n` +
|
|
185
|
-
` * Review and hand-tune as needed.\n` +
|
|
186
|
-
` */\n`;
|
|
187
|
-
|
|
188
|
-
switch (convention) {
|
|
189
|
-
case "loading":
|
|
190
|
-
return `${header}\nexport default function Loading() {\n return ${fallback};\n}\n`;
|
|
191
|
-
case "error":
|
|
192
|
-
return (
|
|
193
|
-
`${header}\n` +
|
|
194
|
-
`export default function Error({ error, reset }: { error: Error; reset: () => void }) {\n` +
|
|
195
|
-
` return ${fallback};\n` +
|
|
196
|
-
`}\n`
|
|
197
|
-
);
|
|
198
|
-
case "not-found":
|
|
199
|
-
return `${header}\nexport default function NotFound() {\n return ${fallback};\n}\n`;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
async function fileExists(absPath: string): Promise<boolean> {
|
|
204
|
-
try {
|
|
205
|
-
const f = Bun.file(absPath);
|
|
206
|
-
return await f.exists();
|
|
207
|
-
} catch {
|
|
208
|
-
return false;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
213
|
-
// Public handler
|
|
214
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
215
|
-
|
|
216
|
-
async function runMigrate(
|
|
217
|
-
projectRoot: string,
|
|
218
|
-
input: MigrateRouteConventionsInput,
|
|
219
|
-
): Promise<
|
|
220
|
-
| MigrateRouteConventionsResult
|
|
221
|
-
| { error: string; field?: string; hint?: string }
|
|
222
|
-
> {
|
|
223
|
-
const validated = validateInput(input as Record<string, unknown>);
|
|
224
|
-
if (!validated.ok) {
|
|
225
|
-
return { error: validated.error, field: validated.field, hint: validated.hint };
|
|
226
|
-
}
|
|
227
|
-
const { dryRun, routes: filter } = validated.value;
|
|
228
|
-
|
|
229
|
-
const routes = await collectRoutes(projectRoot, filter);
|
|
230
|
-
const extracted: MigrateExtractionEntry[] = [];
|
|
231
|
-
const skipped: Array<{ route: string; reason: string }> = [];
|
|
232
|
-
|
|
233
|
-
for (const route of routes) {
|
|
234
|
-
const routeDir = path.join(projectRoot, route);
|
|
235
|
-
|
|
236
|
-
// Find the page file (tsx > ts > jsx > js)
|
|
237
|
-
let pageFile: string | null = null;
|
|
238
|
-
for (const ext of ["tsx", "ts", "jsx", "js"]) {
|
|
239
|
-
const cand = path.join(routeDir, `page.${ext}`);
|
|
240
|
-
if (await fileExists(cand)) {
|
|
241
|
-
pageFile = cand;
|
|
242
|
-
break;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
if (!pageFile) {
|
|
246
|
-
skipped.push({ route, reason: "no page file" });
|
|
247
|
-
continue;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
let source: string;
|
|
251
|
-
try {
|
|
252
|
-
source = await Bun.file(pageFile).text();
|
|
253
|
-
} catch (err) {
|
|
254
|
-
skipped.push({
|
|
255
|
-
route,
|
|
256
|
-
reason: `read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
257
|
-
});
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const hits = detectConventions(source);
|
|
262
|
-
if (hits.length === 0) continue;
|
|
263
|
-
|
|
264
|
-
for (const hit of hits) {
|
|
265
|
-
const target = path.join(routeDir, conventionFilename(hit.convention));
|
|
266
|
-
const relTarget = path.relative(projectRoot, target);
|
|
267
|
-
|
|
268
|
-
if (await fileExists(target)) {
|
|
269
|
-
extracted.push({
|
|
270
|
-
route,
|
|
271
|
-
convention: hit.convention,
|
|
272
|
-
extractedPath: relTarget,
|
|
273
|
-
sourceFile: path.relative(projectRoot, pageFile),
|
|
274
|
-
note: "already exists — skipped write",
|
|
275
|
-
});
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const content = renderConventionFile(hit.convention, hit.fallbackSnippet);
|
|
280
|
-
|
|
281
|
-
if (!dryRun) {
|
|
282
|
-
try {
|
|
283
|
-
await Bun.write(target, content);
|
|
284
|
-
} catch (err) {
|
|
285
|
-
skipped.push({
|
|
286
|
-
route,
|
|
287
|
-
reason: `write ${hit.convention}: ${err instanceof Error ? err.message : String(err)}`,
|
|
288
|
-
});
|
|
289
|
-
continue;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
extracted.push({
|
|
294
|
-
route,
|
|
295
|
-
convention: hit.convention,
|
|
296
|
-
extractedPath: relTarget,
|
|
297
|
-
sourceFile: path.relative(projectRoot, pageFile),
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return { routes, extracted, skipped, dryRun };
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
306
|
-
// MCP tool definition + handler map
|
|
307
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
308
|
-
|
|
309
|
-
export const migrateRouteConventionsToolDefinitions: Tool[] = [
|
|
310
|
-
{
|
|
311
|
-
name: "mandu.refactor.migrate_route_conventions",
|
|
312
|
-
description:
|
|
313
|
-
"Detect Next.js-style inline patterns in `app/**/page.*` files (Suspense fallback, ErrorBoundary, inline NotFound) and extract them to Mandu's file-system route conventions (`loading.tsx`, `error.tsx`, `not-found.tsx`). Never overwrites an existing convention file. Dry-run by default.",
|
|
314
|
-
annotations: {
|
|
315
|
-
readOnlyHint: false,
|
|
316
|
-
destructiveHint: true,
|
|
317
|
-
},
|
|
318
|
-
inputSchema: {
|
|
319
|
-
type: "object",
|
|
320
|
-
properties: {
|
|
321
|
-
dryRun: {
|
|
322
|
-
type: "boolean",
|
|
323
|
-
description:
|
|
324
|
-
"When true (default), return the plan without writing files. When false, create the convention files.",
|
|
325
|
-
},
|
|
326
|
-
routes: {
|
|
327
|
-
type: "array",
|
|
328
|
-
items: { type: "string" },
|
|
329
|
-
description:
|
|
330
|
-
"Optional list of route directories to restrict the scan to. Paths are relative to the project root (e.g. 'app/dashboard').",
|
|
331
|
-
},
|
|
332
|
-
},
|
|
333
|
-
required: [],
|
|
334
|
-
},
|
|
335
|
-
},
|
|
336
|
-
];
|
|
337
|
-
|
|
338
|
-
export function migrateRouteConventionsTools(projectRoot: string) {
|
|
339
|
-
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> =
|
|
340
|
-
{
|
|
341
|
-
"mandu.refactor.migrate_route_conventions": async (args) =>
|
|
342
|
-
runMigrate(projectRoot, args as MigrateRouteConventionsInput),
|
|
343
|
-
};
|
|
344
|
-
return handlers;
|
|
345
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* MCP tool — `mandu.refactor.migrate_route_conventions`
|
|
3
|
+
*
|
|
4
|
+
* Detects Next.js-style inline patterns in user code and migrates them to
|
|
5
|
+
* Mandu's file-system route conventions:
|
|
6
|
+
*
|
|
7
|
+
* • Inline `<Suspense fallback={…}>…</Suspense>` → extract to `loading.tsx`
|
|
8
|
+
* • Inline `<ErrorBoundary fallback={…}>…</ErrorBoundary>` → `error.tsx`
|
|
9
|
+
* • Inline `if (!x) return <NotFound />` / `notFound()` call → `not-found.tsx`
|
|
10
|
+
*
|
|
11
|
+
* Scope:
|
|
12
|
+
* • We only act on files under the `app/` tree (Mandu's routes dir).
|
|
13
|
+
* • We never overwrite an existing convention file — if `loading.tsx`
|
|
14
|
+
* already exists, we report the route as already-migrated.
|
|
15
|
+
* • We do not parse TSX with a real parser — detection uses conservative
|
|
16
|
+
* regex markers. False positives are acceptable; false file writes are
|
|
17
|
+
* not, so we bail out of extraction on any ambiguity.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
21
|
+
import { readdir } from "fs/promises";
|
|
22
|
+
import path from "path";
|
|
23
|
+
|
|
24
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
25
|
+
// Types
|
|
26
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export type RouteConvention = "loading" | "error" | "not-found";
|
|
29
|
+
|
|
30
|
+
export interface MigrateRouteConventionsInput {
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
routes?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MigrateExtractionEntry {
|
|
36
|
+
route: string;
|
|
37
|
+
convention: RouteConvention;
|
|
38
|
+
extractedPath: string;
|
|
39
|
+
sourceFile: string;
|
|
40
|
+
note?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MigrateRouteConventionsResult {
|
|
44
|
+
routes: string[];
|
|
45
|
+
extracted: MigrateExtractionEntry[];
|
|
46
|
+
skipped: Array<{ route: string; reason: string }>;
|
|
47
|
+
dryRun: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
51
|
+
// Validation
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
function validateInput(
|
|
55
|
+
raw: Record<string, unknown>,
|
|
56
|
+
):
|
|
57
|
+
| { ok: true; value: { dryRun: boolean; routes?: string[] } }
|
|
58
|
+
| { ok: false; error: string; field: string; hint: string } {
|
|
59
|
+
const dryRun = raw.dryRun;
|
|
60
|
+
if (dryRun !== undefined && typeof dryRun !== "boolean") {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: "'dryRun' must be a boolean",
|
|
64
|
+
field: "dryRun",
|
|
65
|
+
hint: "Omit to default to true, pass false to actually write files",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const routes = raw.routes;
|
|
69
|
+
if (routes !== undefined) {
|
|
70
|
+
if (!Array.isArray(routes) || !routes.every((r) => typeof r === "string")) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: "'routes' must be an array of strings",
|
|
74
|
+
field: "routes",
|
|
75
|
+
hint: "E.g. ['app/dashboard', 'app/users/[id]']",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
value: {
|
|
82
|
+
dryRun: dryRun === undefined ? true : dryRun,
|
|
83
|
+
...(Array.isArray(routes) ? { routes: routes as string[] } : {}),
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
89
|
+
// Route discovery — a lightweight replacement for fs-scanner that keeps
|
|
90
|
+
// this tool self-contained. We look for `page.ts(x)` files under `app/`.
|
|
91
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async function collectRoutes(
|
|
94
|
+
projectRoot: string,
|
|
95
|
+
filter?: string[],
|
|
96
|
+
): Promise<string[]> {
|
|
97
|
+
const appDir = path.join(projectRoot, "app");
|
|
98
|
+
const found: string[] = [];
|
|
99
|
+
await walkPages(appDir, found);
|
|
100
|
+
const rels = found.map((p) => path.relative(projectRoot, path.dirname(p)));
|
|
101
|
+
if (!filter || filter.length === 0) return rels.sort();
|
|
102
|
+
const set = new Set(filter.map((f) => f.replace(/\\/g, "/")));
|
|
103
|
+
return rels.filter((r) => set.has(r.replace(/\\/g, "/"))).sort();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function walkPages(dir: string, out: string[]): Promise<void> {
|
|
107
|
+
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
108
|
+
try {
|
|
109
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
110
|
+
} catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
for (const e of entries) {
|
|
114
|
+
const full = path.join(dir, e.name);
|
|
115
|
+
if (e.isDirectory()) {
|
|
116
|
+
if (e.name.startsWith("_") || e.name === "node_modules") continue;
|
|
117
|
+
await walkPages(full, out);
|
|
118
|
+
} else if (
|
|
119
|
+
e.isFile() &&
|
|
120
|
+
/^page\.(?:tsx|ts|jsx|js)$/.test(e.name)
|
|
121
|
+
) {
|
|
122
|
+
out.push(full);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
128
|
+
// Detection
|
|
129
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export interface DetectionHit {
|
|
132
|
+
convention: RouteConvention;
|
|
133
|
+
fallbackSnippet: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Scan a single page source for inline Suspense / ErrorBoundary / NotFound
|
|
138
|
+
* patterns. Exported for regression tests.
|
|
139
|
+
*/
|
|
140
|
+
export function detectConventions(source: string): DetectionHit[] {
|
|
141
|
+
const hits: DetectionHit[] = [];
|
|
142
|
+
|
|
143
|
+
// `<Suspense fallback={<Loading />}>`
|
|
144
|
+
const suspense = /<Suspense\s+fallback\s*=\s*\{([^}]*)\}\s*>/.exec(source);
|
|
145
|
+
if (suspense) {
|
|
146
|
+
hits.push({
|
|
147
|
+
convention: "loading",
|
|
148
|
+
fallbackSnippet: suspense[1].trim(),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// `<ErrorBoundary fallback={…}>`
|
|
153
|
+
const errBoundary = /<ErrorBoundary\s+fallback\s*=\s*\{([^}]*)\}\s*>/.exec(source);
|
|
154
|
+
if (errBoundary) {
|
|
155
|
+
hits.push({
|
|
156
|
+
convention: "error",
|
|
157
|
+
fallbackSnippet: errBoundary[1].trim(),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// `if (…) return <NotFound />` — lenient single-line match
|
|
162
|
+
const inlineNotFound =
|
|
163
|
+
/return\s*<NotFound\s*\/?\s*>|notFound\s*\(\s*\)/.exec(source);
|
|
164
|
+
if (inlineNotFound) {
|
|
165
|
+
hits.push({
|
|
166
|
+
convention: "not-found",
|
|
167
|
+
fallbackSnippet: "<div>Not found</div>",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return hits;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function conventionFilename(c: RouteConvention): string {
|
|
175
|
+
return `${c}.tsx`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderConventionFile(
|
|
179
|
+
convention: RouteConvention,
|
|
180
|
+
fallback: string,
|
|
181
|
+
): string {
|
|
182
|
+
const header =
|
|
183
|
+
`/**\n` +
|
|
184
|
+
` * Auto-generated by mandu.refactor.migrate_route_conventions.\n` +
|
|
185
|
+
` * Review and hand-tune as needed.\n` +
|
|
186
|
+
` */\n`;
|
|
187
|
+
|
|
188
|
+
switch (convention) {
|
|
189
|
+
case "loading":
|
|
190
|
+
return `${header}\nexport default function Loading() {\n return ${fallback};\n}\n`;
|
|
191
|
+
case "error":
|
|
192
|
+
return (
|
|
193
|
+
`${header}\n` +
|
|
194
|
+
`export default function Error({ error, reset }: { error: Error; reset: () => void }) {\n` +
|
|
195
|
+
` return ${fallback};\n` +
|
|
196
|
+
`}\n`
|
|
197
|
+
);
|
|
198
|
+
case "not-found":
|
|
199
|
+
return `${header}\nexport default function NotFound() {\n return ${fallback};\n}\n`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function fileExists(absPath: string): Promise<boolean> {
|
|
204
|
+
try {
|
|
205
|
+
const f = Bun.file(absPath);
|
|
206
|
+
return await f.exists();
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
213
|
+
// Public handler
|
|
214
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
async function runMigrate(
|
|
217
|
+
projectRoot: string,
|
|
218
|
+
input: MigrateRouteConventionsInput,
|
|
219
|
+
): Promise<
|
|
220
|
+
| MigrateRouteConventionsResult
|
|
221
|
+
| { error: string; field?: string; hint?: string }
|
|
222
|
+
> {
|
|
223
|
+
const validated = validateInput(input as Record<string, unknown>);
|
|
224
|
+
if (!validated.ok) {
|
|
225
|
+
return { error: validated.error, field: validated.field, hint: validated.hint };
|
|
226
|
+
}
|
|
227
|
+
const { dryRun, routes: filter } = validated.value;
|
|
228
|
+
|
|
229
|
+
const routes = await collectRoutes(projectRoot, filter);
|
|
230
|
+
const extracted: MigrateExtractionEntry[] = [];
|
|
231
|
+
const skipped: Array<{ route: string; reason: string }> = [];
|
|
232
|
+
|
|
233
|
+
for (const route of routes) {
|
|
234
|
+
const routeDir = path.join(projectRoot, route);
|
|
235
|
+
|
|
236
|
+
// Find the page file (tsx > ts > jsx > js)
|
|
237
|
+
let pageFile: string | null = null;
|
|
238
|
+
for (const ext of ["tsx", "ts", "jsx", "js"]) {
|
|
239
|
+
const cand = path.join(routeDir, `page.${ext}`);
|
|
240
|
+
if (await fileExists(cand)) {
|
|
241
|
+
pageFile = cand;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (!pageFile) {
|
|
246
|
+
skipped.push({ route, reason: "no page file" });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let source: string;
|
|
251
|
+
try {
|
|
252
|
+
source = await Bun.file(pageFile).text();
|
|
253
|
+
} catch (err) {
|
|
254
|
+
skipped.push({
|
|
255
|
+
route,
|
|
256
|
+
reason: `read failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const hits = detectConventions(source);
|
|
262
|
+
if (hits.length === 0) continue;
|
|
263
|
+
|
|
264
|
+
for (const hit of hits) {
|
|
265
|
+
const target = path.join(routeDir, conventionFilename(hit.convention));
|
|
266
|
+
const relTarget = path.relative(projectRoot, target);
|
|
267
|
+
|
|
268
|
+
if (await fileExists(target)) {
|
|
269
|
+
extracted.push({
|
|
270
|
+
route,
|
|
271
|
+
convention: hit.convention,
|
|
272
|
+
extractedPath: relTarget,
|
|
273
|
+
sourceFile: path.relative(projectRoot, pageFile),
|
|
274
|
+
note: "already exists — skipped write",
|
|
275
|
+
});
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const content = renderConventionFile(hit.convention, hit.fallbackSnippet);
|
|
280
|
+
|
|
281
|
+
if (!dryRun) {
|
|
282
|
+
try {
|
|
283
|
+
await Bun.write(target, content);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
skipped.push({
|
|
286
|
+
route,
|
|
287
|
+
reason: `write ${hit.convention}: ${err instanceof Error ? err.message : String(err)}`,
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
extracted.push({
|
|
294
|
+
route,
|
|
295
|
+
convention: hit.convention,
|
|
296
|
+
extractedPath: relTarget,
|
|
297
|
+
sourceFile: path.relative(projectRoot, pageFile),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { routes, extracted, skipped, dryRun };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
306
|
+
// MCP tool definition + handler map
|
|
307
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
export const migrateRouteConventionsToolDefinitions: Tool[] = [
|
|
310
|
+
{
|
|
311
|
+
name: "mandu.refactor.migrate_route_conventions",
|
|
312
|
+
description:
|
|
313
|
+
"Detect Next.js-style inline patterns in `app/**/page.*` files (Suspense fallback, ErrorBoundary, inline NotFound) and extract them to Mandu's file-system route conventions (`loading.tsx`, `error.tsx`, `not-found.tsx`). Never overwrites an existing convention file. Dry-run by default.",
|
|
314
|
+
annotations: {
|
|
315
|
+
readOnlyHint: false,
|
|
316
|
+
destructiveHint: true,
|
|
317
|
+
},
|
|
318
|
+
inputSchema: {
|
|
319
|
+
type: "object",
|
|
320
|
+
properties: {
|
|
321
|
+
dryRun: {
|
|
322
|
+
type: "boolean",
|
|
323
|
+
description:
|
|
324
|
+
"When true (default), return the plan without writing files. When false, create the convention files.",
|
|
325
|
+
},
|
|
326
|
+
routes: {
|
|
327
|
+
type: "array",
|
|
328
|
+
items: { type: "string" },
|
|
329
|
+
description:
|
|
330
|
+
"Optional list of route directories to restrict the scan to. Paths are relative to the project root (e.g. 'app/dashboard').",
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
required: [],
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
];
|
|
337
|
+
|
|
338
|
+
export function migrateRouteConventionsTools(projectRoot: string) {
|
|
339
|
+
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> =
|
|
340
|
+
{
|
|
341
|
+
"mandu.refactor.migrate_route_conventions": async (args) =>
|
|
342
|
+
runMigrate(projectRoot, args as MigrateRouteConventionsInput),
|
|
343
|
+
};
|
|
344
|
+
return handlers;
|
|
345
|
+
}
|