@mandujs/mcp 0.20.7 → 0.21.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/mcp",
3
- "version": "0.20.7",
3
+ "version": "0.21.0",
4
4
  "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -34,9 +34,9 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@mandujs/core": "^0.30.0",
37
+ "@mandujs/core": "^0.31.0",
38
38
  "@mandujs/ate": "^0.19.1",
39
- "@mandujs/skills": "^9.0.0",
39
+ "@mandujs/skills": "^10.0.0",
40
40
  "@modelcontextprotocol/sdk": "^1.25.3"
41
41
  },
42
42
  "engines": {
@@ -0,0 +1,406 @@
1
+ /**
2
+ * MCP tool — `mandu.refactor.extract_contract`
3
+ *
4
+ * Scans `app/api/**\/route.ts` for inline ad-hoc Zod schemas and extracts
5
+ * each into a sibling contract module following the `defineContract()`
6
+ * convention from `@mandujs/core`.
7
+ *
8
+ * Detection is conservative: we look for `z.object({ … })` literals bound
9
+ * to a local identifier (`const FooSchema = z.object({ … })`). We do NOT
10
+ * attempt to re-parse the route handler — extraction emits a new file and
11
+ * leaves a `TODO` comment next to each source site instructing the author
12
+ * to swap to the contract import. The intent is "halfway refactor"
13
+ * assistance, not a full AST-level transform.
14
+ *
15
+ * Output format:
16
+ * For `app/api/users/route.ts` containing
17
+ * `const CreateUser = z.object({ name: z.string() });`
18
+ * we emit `contract/users.contract.ts`:
19
+ * ```ts
20
+ * import { z } from "zod";
21
+ * import { defineContract } from "@mandujs/core";
22
+ * export const usersContract = defineContract({
23
+ * create: {
24
+ * method: "POST",
25
+ * path: "/api/users",
26
+ * input: z.object({ name: z.string() }),
27
+ * output: z.unknown(),
28
+ * },
29
+ * });
30
+ * ```
31
+ */
32
+
33
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
34
+ import { readdir } from "fs/promises";
35
+ import path from "path";
36
+
37
+ // ─────────────────────────────────────────────────────────────────────────
38
+ // Types
39
+ // ─────────────────────────────────────────────────────────────────────────
40
+
41
+ export interface ExtractContractInput {
42
+ dryRun?: boolean;
43
+ route?: string;
44
+ }
45
+
46
+ export interface ExtractedContractEntry {
47
+ route: string;
48
+ contractFile: string;
49
+ schemaName: string;
50
+ sourceFile: string;
51
+ }
52
+
53
+ export interface ExtractContractResult {
54
+ extracted: ExtractedContractEntry[];
55
+ skipped: Array<{ route: string; reason: string }>;
56
+ dryRun: boolean;
57
+ }
58
+
59
+ // ─────────────────────────────────────────────────────────────────────────
60
+ // Validation
61
+ // ─────────────────────────────────────────────────────────────────────────
62
+
63
+ function validateInput(
64
+ raw: Record<string, unknown>,
65
+ ):
66
+ | { ok: true; value: { dryRun: boolean; route?: string } }
67
+ | { ok: false; error: string; field: string; hint: string } {
68
+ const dryRun = raw.dryRun;
69
+ if (dryRun !== undefined && typeof dryRun !== "boolean") {
70
+ return {
71
+ ok: false,
72
+ error: "'dryRun' must be a boolean",
73
+ field: "dryRun",
74
+ hint: "Omit to default to true",
75
+ };
76
+ }
77
+ const route = raw.route;
78
+ if (route !== undefined && typeof route !== "string") {
79
+ return {
80
+ ok: false,
81
+ error: "'route' must be a string",
82
+ field: "route",
83
+ hint: "E.g. 'app/api/users'",
84
+ };
85
+ }
86
+ return {
87
+ ok: true,
88
+ value: {
89
+ dryRun: dryRun === undefined ? true : dryRun,
90
+ ...(typeof route === "string" ? { route } : {}),
91
+ },
92
+ };
93
+ }
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────
96
+ // Route discovery — find `app/api/**\/route.ts(x)` files
97
+ // ─────────────────────────────────────────────────────────────────────────
98
+
99
+ async function findApiRoutes(projectRoot: string): Promise<string[]> {
100
+ const apiDir = path.join(projectRoot, "app", "api");
101
+ const out: string[] = [];
102
+ await walkApi(apiDir, out);
103
+ return out.sort();
104
+ }
105
+
106
+ async function walkApi(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 === "node_modules" || e.name.startsWith("_")) continue;
117
+ await walkApi(full, out);
118
+ } else if (e.isFile() && /^route\.(?:tsx|ts|jsx|js)$/.test(e.name)) {
119
+ out.push(full);
120
+ }
121
+ }
122
+ }
123
+
124
+ // ─────────────────────────────────────────────────────────────────────────
125
+ // Inline-schema detection
126
+ // ─────────────────────────────────────────────────────────────────────────
127
+
128
+ export interface DetectedSchema {
129
+ name: string;
130
+ /** The balanced `z.object({ … })` literal. */
131
+ body: string;
132
+ /** The HTTP method associated (heuristic from exported fn names). */
133
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
134
+ }
135
+
136
+ /**
137
+ * Walk forward from `z.object(` and return the index just past the
138
+ * matching `)` using brace-depth tracking. Returns -1 if unbalanced.
139
+ */
140
+ export function findZodObjectEnd(source: string, start: number): number {
141
+ // Expect `z.object(` at `start`.
142
+ const open = source.indexOf("(", start);
143
+ if (open < 0) return -1;
144
+ let depth = 0;
145
+ for (let i = open; i < source.length; i++) {
146
+ const ch = source[i];
147
+ if (ch === "(" || ch === "{" || ch === "[") depth++;
148
+ else if (ch === ")" || ch === "}" || ch === "]") {
149
+ depth--;
150
+ if (depth === 0 && ch === ")") return i + 1;
151
+ }
152
+ }
153
+ return -1;
154
+ }
155
+
156
+ /**
157
+ * Detect inline Zod schemas bound to a const identifier.
158
+ *
159
+ * Exported for regression tests.
160
+ */
161
+ export function detectInlineSchemas(source: string): DetectedSchema[] {
162
+ const out: DetectedSchema[] = [];
163
+ const constRegex = /\bconst\s+(\w+)\s*=\s*z\.object\s*\(/g;
164
+ let m: RegExpExecArray | null;
165
+ while ((m = constRegex.exec(source)) !== null) {
166
+ const name = m[1];
167
+ const openZ = source.indexOf("z.object", m.index);
168
+ if (openZ < 0) continue;
169
+ const end = findZodObjectEnd(source, openZ);
170
+ if (end < 0) continue;
171
+ const body = source.slice(openZ, end);
172
+ out.push({
173
+ name,
174
+ body,
175
+ method: inferMethod(source, name),
176
+ });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function inferMethod(
182
+ source: string,
183
+ schemaName: string,
184
+ ): "GET" | "POST" | "PUT" | "PATCH" | "DELETE" {
185
+ // Heuristic: look at which exported HTTP handler mentions the schema.
186
+ const methods = ["POST", "PUT", "PATCH", "DELETE", "GET"] as const;
187
+ for (const method of methods) {
188
+ const re = new RegExp(
189
+ `export\\s+(?:async\\s+)?function\\s+${method}\\b[\\s\\S]*?\\b${schemaName}\\b`,
190
+ );
191
+ if (re.test(source)) return method;
192
+ }
193
+ // Fallback: if the schema name hints at mutation, guess POST.
194
+ if (/create|update|delete|patch/i.test(schemaName)) return "POST";
195
+ return "GET";
196
+ }
197
+
198
+ // ─────────────────────────────────────────────────────────────────────────
199
+ // Emit contract file
200
+ // ─────────────────────────────────────────────────────────────────────────
201
+
202
+ export interface ContractModuleOutput {
203
+ fileName: string;
204
+ contractName: string;
205
+ source: string;
206
+ }
207
+
208
+ /**
209
+ * Build a `contract/<name>.contract.ts` source for the given route +
210
+ * detected schemas. Exported for tests.
211
+ */
212
+ export function renderContractModule(
213
+ routeRel: string,
214
+ schemas: DetectedSchema[],
215
+ ): ContractModuleOutput {
216
+ // routeRel like `app/api/users/route.ts` → group name `users`
217
+ const parts = routeRel.split(/[\\/]/);
218
+ const apiIdx = parts.indexOf("api");
219
+ const segs = apiIdx >= 0 ? parts.slice(apiIdx + 1) : parts.slice(-2);
220
+ const group =
221
+ segs
222
+ .filter((s) => s !== "route.ts" && s !== "route.tsx")
223
+ .filter(Boolean)
224
+ .join("-")
225
+ .replace(/[\[\]]/g, "")
226
+ .replace(/[^A-Za-z0-9_-]/g, "") || "api";
227
+
228
+ const contractName = `${camelize(group)}Contract`;
229
+ const urlPath = "/api/" + segs.filter((s) => !/^route\./.test(s)).join("/");
230
+
231
+ const endpoints = schemas
232
+ .map((s) => {
233
+ const opKey = deriveOpKey(s.name, s.method);
234
+ return (
235
+ ` ${opKey}: {\n` +
236
+ ` method: ${JSON.stringify(s.method)},\n` +
237
+ ` path: ${JSON.stringify(urlPath)},\n` +
238
+ ` input: ${s.body},\n` +
239
+ ` output: z.unknown(),\n` +
240
+ ` },`
241
+ );
242
+ })
243
+ .join("\n");
244
+
245
+ const src =
246
+ `/**\n` +
247
+ ` * Auto-generated by mandu.refactor.extract_contract.\n` +
248
+ ` * Replace output: z.unknown() with your response schema.\n` +
249
+ ` */\n` +
250
+ `import { z } from "zod";\n` +
251
+ `import { defineContract } from "@mandujs/core";\n\n` +
252
+ `export const ${contractName} = defineContract({\n` +
253
+ `${endpoints}\n` +
254
+ `});\n`;
255
+
256
+ return {
257
+ fileName: `${group}.contract.ts`,
258
+ contractName,
259
+ source: src,
260
+ };
261
+ }
262
+
263
+ function camelize(s: string): string {
264
+ return s
265
+ .split(/[-_]/)
266
+ .map((part, i) =>
267
+ i === 0 ? part.toLowerCase() : part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
268
+ )
269
+ .join("");
270
+ }
271
+
272
+ function deriveOpKey(
273
+ schemaName: string,
274
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
275
+ ): string {
276
+ const lower = schemaName.toLowerCase();
277
+ if (lower.includes("create")) return "create";
278
+ if (lower.includes("update") || lower.includes("patch")) return "update";
279
+ if (lower.includes("delete")) return "remove";
280
+ if (lower.includes("list") || lower.includes("query")) return "list";
281
+ return method === "GET" ? "read" : "mutate";
282
+ }
283
+
284
+ // ─────────────────────────────────────────────────────────────────────────
285
+ // Public handler
286
+ // ─────────────────────────────────────────────────────────────────────────
287
+
288
+ async function runExtract(
289
+ projectRoot: string,
290
+ input: ExtractContractInput,
291
+ ): Promise<
292
+ | ExtractContractResult
293
+ | { error: string; field?: string; hint?: string }
294
+ > {
295
+ const validated = validateInput(input as Record<string, unknown>);
296
+ if (!validated.ok) {
297
+ return { error: validated.error, field: validated.field, hint: validated.hint };
298
+ }
299
+ const { dryRun, route: filter } = validated.value;
300
+
301
+ const routes = await findApiRoutes(projectRoot);
302
+ const extracted: ExtractedContractEntry[] = [];
303
+ const skipped: Array<{ route: string; reason: string }> = [];
304
+
305
+ const contractDir = path.join(projectRoot, "contract");
306
+
307
+ for (const routeFile of routes) {
308
+ const routeRel = path.relative(projectRoot, routeFile).replace(/\\/g, "/");
309
+ const routeDirRel = routeRel.replace(/\/route\.(?:tsx?|jsx?)$/, "");
310
+ if (filter && !routeDirRel.startsWith(filter.replace(/\\/g, "/"))) continue;
311
+
312
+ let source: string;
313
+ try {
314
+ source = await Bun.file(routeFile).text();
315
+ } catch (err) {
316
+ skipped.push({
317
+ route: routeDirRel,
318
+ reason: `read failed: ${err instanceof Error ? err.message : String(err)}`,
319
+ });
320
+ continue;
321
+ }
322
+
323
+ let schemas: DetectedSchema[];
324
+ try {
325
+ schemas = detectInlineSchemas(source);
326
+ } catch (err) {
327
+ skipped.push({
328
+ route: routeDirRel,
329
+ reason: `parse error: ${err instanceof Error ? err.message : String(err)}`,
330
+ });
331
+ continue;
332
+ }
333
+ if (schemas.length === 0) {
334
+ skipped.push({ route: routeDirRel, reason: "no inline z.object schemas" });
335
+ continue;
336
+ }
337
+
338
+ const out = renderContractModule(routeRel, schemas);
339
+ const target = path.join(contractDir, out.fileName);
340
+ const targetRel = path.relative(projectRoot, target).replace(/\\/g, "/");
341
+
342
+ if (!dryRun) {
343
+ try {
344
+ await Bun.write(target, out.source);
345
+ } catch (err) {
346
+ skipped.push({
347
+ route: routeDirRel,
348
+ reason: `write failed: ${err instanceof Error ? err.message : String(err)}`,
349
+ });
350
+ continue;
351
+ }
352
+ }
353
+
354
+ for (const s of schemas) {
355
+ extracted.push({
356
+ route: routeDirRel,
357
+ contractFile: targetRel,
358
+ schemaName: s.name,
359
+ sourceFile: routeRel,
360
+ });
361
+ }
362
+ }
363
+
364
+ return { extracted, skipped, dryRun };
365
+ }
366
+
367
+ // ─────────────────────────────────────────────────────────────────────────
368
+ // MCP tool definition + handler map
369
+ // ─────────────────────────────────────────────────────────────────────────
370
+
371
+ export const extractContractToolDefinitions: Tool[] = [
372
+ {
373
+ name: "mandu.refactor.extract_contract",
374
+ description:
375
+ "Scan `app/api/**/route.ts` for inline Zod schemas and extract them to `contract/<group>.contract.ts` using the `defineContract()` convention. The source handler is left untouched — follow-up manual step is to import the contract and swap ad-hoc validation. Dry-run by default.",
376
+ annotations: {
377
+ readOnlyHint: false,
378
+ destructiveHint: true,
379
+ },
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ dryRun: {
384
+ type: "boolean",
385
+ description:
386
+ "When true (default), return the plan without writing files. When false, write the contract files to `contract/`.",
387
+ },
388
+ route: {
389
+ type: "string",
390
+ description:
391
+ "Optional prefix to restrict scan (e.g. 'app/api/users').",
392
+ },
393
+ },
394
+ required: [],
395
+ },
396
+ },
397
+ ];
398
+
399
+ export function extractContractTools(projectRoot: string) {
400
+ const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> =
401
+ {
402
+ "mandu.refactor.extract_contract": async (args) =>
403
+ runExtract(projectRoot, args as ExtractContractInput),
404
+ };
405
+ return handlers;
406
+ }
@@ -37,6 +37,19 @@ export { runTestsTools, runTestsToolDefinitions } from "./run-tests.js";
37
37
  export { deployPreviewTools, deployPreviewToolDefinitions } from "./deploy-preview.js";
38
38
  export { aiBriefTools, aiBriefToolDefinitions } from "./ai-brief.js";
39
39
  export { loopCloseTools, loopCloseToolDefinitions } from "./loop-close.js";
40
+ // Phase 18.ι — AI refactor MCP tools
41
+ export {
42
+ rewriteGeneratedBarrelTools,
43
+ rewriteGeneratedBarrelToolDefinitions,
44
+ } from "./rewrite-generated-barrel.js";
45
+ export {
46
+ migrateRouteConventionsTools,
47
+ migrateRouteConventionsToolDefinitions,
48
+ } from "./migrate-route-conventions.js";
49
+ export {
50
+ extractContractTools,
51
+ extractContractToolDefinitions,
52
+ } from "./extract-contract.js";
40
53
 
41
54
  // 도구 모듈 import (등록용)
42
55
  import { specTools, specToolDefinitions } from "./spec.js";
@@ -63,6 +76,19 @@ import { runTestsTools, runTestsToolDefinitions } from "./run-tests.js";
63
76
  import { deployPreviewTools, deployPreviewToolDefinitions } from "./deploy-preview.js";
64
77
  import { aiBriefTools, aiBriefToolDefinitions } from "./ai-brief.js";
65
78
  import { loopCloseTools, loopCloseToolDefinitions } from "./loop-close.js";
79
+ // Phase 18.ι — AI refactor MCP tools
80
+ import {
81
+ rewriteGeneratedBarrelTools,
82
+ rewriteGeneratedBarrelToolDefinitions,
83
+ } from "./rewrite-generated-barrel.js";
84
+ import {
85
+ migrateRouteConventionsTools,
86
+ migrateRouteConventionsToolDefinitions,
87
+ } from "./migrate-route-conventions.js";
88
+ import {
89
+ extractContractTools,
90
+ extractContractToolDefinitions,
91
+ } from "./extract-contract.js";
66
92
 
67
93
  /**
68
94
  * 도구 모듈 정보
@@ -108,6 +134,22 @@ const TOOL_MODULES: ToolModule[] = [
108
134
  { category: "deploy-preview", definitions: deployPreviewToolDefinitions, handlers: deployPreviewTools },
109
135
  { category: "ai-brief", definitions: aiBriefToolDefinitions, handlers: aiBriefTools },
110
136
  { category: "loop-close", definitions: loopCloseToolDefinitions, handlers: loopCloseTools },
137
+ // Phase 18.ι — AI refactor tools (destructive writes; dry-run by default)
138
+ {
139
+ category: "refactor-barrel",
140
+ definitions: rewriteGeneratedBarrelToolDefinitions,
141
+ handlers: rewriteGeneratedBarrelTools,
142
+ },
143
+ {
144
+ category: "refactor-routes",
145
+ definitions: migrateRouteConventionsToolDefinitions,
146
+ handlers: migrateRouteConventionsTools,
147
+ },
148
+ {
149
+ category: "refactor-contract",
150
+ definitions: extractContractToolDefinitions,
151
+ handlers: extractContractTools,
152
+ },
111
153
  ];
112
154
 
113
155
  /**
@@ -0,0 +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
+ }
@@ -0,0 +1,403 @@
1
+ /**
2
+ * MCP tool — `mandu.refactor.rewrite_generated_barrel`
3
+ *
4
+ * Automates migration of barrel files that reach directly into
5
+ * `__generated__/*` paths (the #1 cause of `INVALID_GENERATED_IMPORT` guard
6
+ * failures) to the sanctioned `getGenerated()` accessor from
7
+ * `@mandujs/core/runtime`.
8
+ *
9
+ * Transform example:
10
+ *
11
+ * // BEFORE
12
+ * export { items } from "../__generated__/items.data";
13
+ *
14
+ * // AFTER
15
+ * import { getGenerated } from "@mandujs/core/runtime";
16
+ * declare module "@mandujs/core/runtime" {
17
+ * interface GeneratedRegistry { "items": typeof items; }
18
+ * }
19
+ * export const items = getGenerated("items");
20
+ *
21
+ * Behaviour:
22
+ * • Input: `{ dryRun?: boolean, patterns?: string[] }`. `patterns` are
23
+ * relative glob-like roots to scan (default: `packages/*`, `src/**`).
24
+ * The implementation uses a deterministic recursive walk — we do not
25
+ * pull in a glob dep.
26
+ * • Each file is parsed with a minimal, conservative regex set that only
27
+ * matches re-exports of the form `export { … } from "…/__generated__/…"`.
28
+ * Anything more exotic is reported under `skipped` with a reason.
29
+ * • On `dryRun: true` (default) we only return the plan. On
30
+ * `dryRun: false` we write files via `Bun.write`, which is atomic.
31
+ * • Parse / I/O errors for a single file are captured and do not abort
32
+ * the scan.
33
+ */
34
+
35
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
36
+ import { readdir } from "fs/promises";
37
+ import path from "path";
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────
40
+ // Types
41
+ // ─────────────────────────────────────────────────────────────────────────
42
+
43
+ export interface RewriteBarrelInput {
44
+ dryRun?: boolean;
45
+ patterns?: string[];
46
+ }
47
+
48
+ export interface RewriteBarrelPlanEntry {
49
+ file: string;
50
+ before: string;
51
+ after: string;
52
+ rewrites: Array<{ name: string; key: string; source: string }>;
53
+ appliedIf: "not-dry-run";
54
+ }
55
+
56
+ export interface RewriteBarrelSkip {
57
+ file: string;
58
+ reason: string;
59
+ }
60
+
61
+ export interface RewriteBarrelResult {
62
+ scanned: number;
63
+ matched: number;
64
+ rewritten: number;
65
+ skipped: RewriteBarrelSkip[];
66
+ plan: RewriteBarrelPlanEntry[];
67
+ dryRun: boolean;
68
+ }
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────
71
+ // Validation
72
+ // ─────────────────────────────────────────────────────────────────────────
73
+
74
+ function validateInput(
75
+ raw: Record<string, unknown>,
76
+ ):
77
+ | { ok: true; value: { dryRun: boolean; patterns: string[] } }
78
+ | { ok: false; error: string; field: string; hint: string } {
79
+ const dryRun = raw.dryRun;
80
+ if (dryRun !== undefined && typeof dryRun !== "boolean") {
81
+ return {
82
+ ok: false,
83
+ error: "'dryRun' must be a boolean",
84
+ field: "dryRun",
85
+ hint: "Omit to default to true, pass false to actually write files",
86
+ };
87
+ }
88
+
89
+ const patterns = raw.patterns;
90
+ if (patterns !== undefined) {
91
+ if (!Array.isArray(patterns) || !patterns.every((p) => typeof p === "string")) {
92
+ return {
93
+ ok: false,
94
+ error: "'patterns' must be an array of strings",
95
+ field: "patterns",
96
+ hint: "E.g. ['packages', 'src']",
97
+ };
98
+ }
99
+ }
100
+
101
+ return {
102
+ ok: true,
103
+ value: {
104
+ dryRun: dryRun === undefined ? true : dryRun,
105
+ patterns:
106
+ patterns && Array.isArray(patterns) && patterns.length > 0
107
+ ? (patterns as string[])
108
+ : ["packages", "src"],
109
+ },
110
+ };
111
+ }
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────
114
+ // FS walk
115
+ // ─────────────────────────────────────────────────────────────────────────
116
+
117
+ const EXT_REGEX = /\.(?:ts|tsx|mts|cts)$/;
118
+ const IGNORE_DIRS = new Set([
119
+ "node_modules",
120
+ ".mandu",
121
+ "dist",
122
+ "build",
123
+ ".git",
124
+ ".next",
125
+ "coverage",
126
+ "__generated__",
127
+ ]);
128
+
129
+ async function walk(root: string, dir: string, out: string[]): Promise<void> {
130
+ let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
131
+ try {
132
+ entries = await readdir(dir, { withFileTypes: true });
133
+ } catch {
134
+ return;
135
+ }
136
+ for (const e of entries) {
137
+ const full = path.join(dir, e.name);
138
+ if (e.isDirectory()) {
139
+ if (IGNORE_DIRS.has(e.name)) continue;
140
+ await walk(root, full, out);
141
+ } else if (e.isFile() && EXT_REGEX.test(e.name)) {
142
+ out.push(full);
143
+ }
144
+ }
145
+ }
146
+
147
+ async function collectFiles(projectRoot: string, patterns: string[]): Promise<string[]> {
148
+ const collected = new Set<string>();
149
+ for (const rel of patterns) {
150
+ const abs = path.resolve(projectRoot, rel);
151
+ const files: string[] = [];
152
+ await walk(projectRoot, abs, files);
153
+ for (const f of files) collected.add(f);
154
+ }
155
+ return [...collected].sort();
156
+ }
157
+
158
+ // ─────────────────────────────────────────────────────────────────────────
159
+ // Rewrite engine
160
+ // ─────────────────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Regex matching a re-export of the form:
164
+ * export { a, b as c } from "…/__generated__/name.data";
165
+ *
166
+ * Captures:
167
+ * [1] = names block (e.g. `a, b as c`)
168
+ * [2] = source path
169
+ */
170
+ const GENERATED_REEXPORT_REGEX =
171
+ /export\s*\{\s*([^}]+)\s*\}\s*from\s*["']([^"']*__generated__[^"']*)["']\s*;?/g;
172
+
173
+ /** Parse a names-block `a, b as c, default as d` → [{name, alias?}]. */
174
+ function parseNames(namesBlock: string): Array<{ name: string; alias?: string }> {
175
+ return namesBlock
176
+ .split(",")
177
+ .map((s) => s.trim())
178
+ .filter(Boolean)
179
+ .map((tok) => {
180
+ const asMatch = /^(\S+)\s+as\s+(\S+)$/.exec(tok);
181
+ if (asMatch) return { name: asMatch[1], alias: asMatch[2] };
182
+ return { name: tok };
183
+ });
184
+ }
185
+
186
+ /** Derive a registry key from a __generated__ source path. */
187
+ export function deriveGeneratedKey(source: string): string {
188
+ // `../__generated__/items.data` → `items`
189
+ // `./__generated__/foo/bar.data` → `foo/bar`
190
+ const idx = source.indexOf("__generated__/");
191
+ const tail = idx >= 0 ? source.slice(idx + "__generated__/".length) : source;
192
+ return tail.replace(/\.(?:data|index|gen|g)$/, "").replace(/\/index$/, "");
193
+ }
194
+
195
+ export interface RewriteOutcome {
196
+ after: string;
197
+ rewrites: Array<{ name: string; key: string; source: string }>;
198
+ }
199
+
200
+ /**
201
+ * Rewrite the source of a single barrel file. Returns `null` when no
202
+ * `__generated__` re-export is present (nothing to do).
203
+ *
204
+ * Exported for regression testing.
205
+ */
206
+ export function rewriteBarrelSource(before: string): RewriteOutcome | null {
207
+ // Fast bail: no hits at all.
208
+ if (!/__generated__/.test(before)) return null;
209
+
210
+ const rewrites: Array<{ name: string; key: string; source: string }> = [];
211
+ let matched = false;
212
+
213
+ // Build an accumulator of replacement blocks; each match becomes a
214
+ // declare-module + const block.
215
+ const replaced = before.replace(
216
+ GENERATED_REEXPORT_REGEX,
217
+ (_full, namesBlock: string, source: string) => {
218
+ matched = true;
219
+ const key = deriveGeneratedKey(source);
220
+ const names = parseNames(namesBlock);
221
+ const constDecls: string[] = [];
222
+ const typeLines: string[] = [];
223
+ for (const n of names) {
224
+ const exported = n.alias ?? n.name;
225
+ // Per re-export we emit a single registry key but re-expose each
226
+ // symbol as its own const. The registry key is derived from the
227
+ // file path — all symbols in the same re-export share it and are
228
+ // expected to live on the same generated artifact. When multiple
229
+ // symbols come from one source we destructure.
230
+ if (names.length === 1) {
231
+ constDecls.push(
232
+ `export const ${exported} = getGenerated(${JSON.stringify(key)});`,
233
+ );
234
+ typeLines.push(` ${JSON.stringify(key)}: typeof ${exported};`);
235
+ } else {
236
+ // First rewrite emits the shared const; subsequent destructures
237
+ // pull the field off of it.
238
+ if (constDecls.length === 0) {
239
+ constDecls.push(
240
+ `const __${sanitize(key)} = getGenerated(${JSON.stringify(key)});`,
241
+ );
242
+ }
243
+ constDecls.push(
244
+ `export const ${exported} = __${sanitize(key)}.${n.name};`,
245
+ );
246
+ typeLines.push(
247
+ ` ${JSON.stringify(key)}: { ${names
248
+ .map((m) => `${m.name}: typeof ${m.alias ?? m.name};`)
249
+ .join(" ")} };`,
250
+ );
251
+ }
252
+ rewrites.push({ name: exported, key, source });
253
+ }
254
+
255
+ const dedupedTypeLines = [...new Set(typeLines)];
256
+ return [
257
+ `declare module "@mandujs/core/runtime" {`,
258
+ ` interface GeneratedRegistry {`,
259
+ ...dedupedTypeLines,
260
+ ` }`,
261
+ `}`,
262
+ ...constDecls,
263
+ ].join("\n");
264
+ },
265
+ );
266
+
267
+ if (!matched) return null;
268
+
269
+ // Ensure a single `import { getGenerated } from "@mandujs/core/runtime"`
270
+ // is present. If the file already imports it, we leave it alone.
271
+ const hasImport =
272
+ /import\s*\{[^}]*\bgetGenerated\b[^}]*\}\s*from\s*["']@mandujs\/core\/runtime["']/.test(
273
+ replaced,
274
+ );
275
+ const after = hasImport
276
+ ? replaced
277
+ : `import { getGenerated } from "@mandujs/core/runtime";\n${replaced}`;
278
+
279
+ return { after, rewrites };
280
+ }
281
+
282
+ function sanitize(s: string): string {
283
+ return s.replace(/[^A-Za-z0-9_]/g, "_");
284
+ }
285
+
286
+ // ─────────────────────────────────────────────────────────────────────────
287
+ // Public handler
288
+ // ─────────────────────────────────────────────────────────────────────────
289
+
290
+ async function runRewrite(
291
+ projectRoot: string,
292
+ input: RewriteBarrelInput,
293
+ ): Promise<RewriteBarrelResult | { error: string; field?: string; hint?: string }> {
294
+ const validated = validateInput(input as Record<string, unknown>);
295
+ if (!validated.ok) {
296
+ return { error: validated.error, field: validated.field, hint: validated.hint };
297
+ }
298
+ const { dryRun, patterns } = validated.value;
299
+
300
+ const files = await collectFiles(projectRoot, patterns);
301
+
302
+ const plan: RewriteBarrelPlanEntry[] = [];
303
+ const skipped: RewriteBarrelSkip[] = [];
304
+ let rewritten = 0;
305
+
306
+ for (const file of files) {
307
+ let before: string;
308
+ try {
309
+ before = await Bun.file(file).text();
310
+ } catch (err) {
311
+ skipped.push({
312
+ file: path.relative(projectRoot, file),
313
+ reason: `read failed: ${err instanceof Error ? err.message : String(err)}`,
314
+ });
315
+ continue;
316
+ }
317
+
318
+ let outcome: RewriteOutcome | null = null;
319
+ try {
320
+ outcome = rewriteBarrelSource(before);
321
+ } catch (err) {
322
+ skipped.push({
323
+ file: path.relative(projectRoot, file),
324
+ reason: `parse error: ${err instanceof Error ? err.message : String(err)}`,
325
+ });
326
+ continue;
327
+ }
328
+
329
+ if (!outcome) continue; // no __generated__ re-export, irrelevant
330
+
331
+ const rel = path.relative(projectRoot, file);
332
+ plan.push({
333
+ file: rel,
334
+ before,
335
+ after: outcome.after,
336
+ rewrites: outcome.rewrites,
337
+ appliedIf: "not-dry-run",
338
+ });
339
+
340
+ if (!dryRun) {
341
+ try {
342
+ await Bun.write(file, outcome.after);
343
+ rewritten += 1;
344
+ } catch (err) {
345
+ skipped.push({
346
+ file: rel,
347
+ reason: `write failed: ${err instanceof Error ? err.message : String(err)}`,
348
+ });
349
+ }
350
+ }
351
+ }
352
+
353
+ return {
354
+ scanned: files.length,
355
+ matched: plan.length,
356
+ rewritten,
357
+ skipped,
358
+ plan,
359
+ dryRun,
360
+ };
361
+ }
362
+
363
+ // ─────────────────────────────────────────────────────────────────────────
364
+ // MCP tool definition + handler map
365
+ // ─────────────────────────────────────────────────────────────────────────
366
+
367
+ export const rewriteGeneratedBarrelToolDefinitions: Tool[] = [
368
+ {
369
+ name: "mandu.refactor.rewrite_generated_barrel",
370
+ description:
371
+ "Scan the project for barrel files that re-export from `__generated__/*` paths (a `INVALID_GENERATED_IMPORT` guard violation) and rewrite each to use `getGenerated()` from `@mandujs/core/runtime` with the proper `GeneratedRegistry` module augmentation. Returns a per-file before/after plan. Dry-run by default.",
372
+ annotations: {
373
+ readOnlyHint: false,
374
+ destructiveHint: true,
375
+ },
376
+ inputSchema: {
377
+ type: "object",
378
+ properties: {
379
+ dryRun: {
380
+ type: "boolean",
381
+ description:
382
+ "When true (default), return the plan without writing files. When false, rewrite files atomically via Bun.write.",
383
+ },
384
+ patterns: {
385
+ type: "array",
386
+ items: { type: "string" },
387
+ description:
388
+ "Relative directory roots to scan (default: ['packages', 'src']).",
389
+ },
390
+ },
391
+ required: [],
392
+ },
393
+ },
394
+ ];
395
+
396
+ export function rewriteGeneratedBarrelTools(projectRoot: string) {
397
+ const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> =
398
+ {
399
+ "mandu.refactor.rewrite_generated_barrel": async (args) =>
400
+ runRewrite(projectRoot, args as RewriteBarrelInput),
401
+ };
402
+ return handlers;
403
+ }