@mandujs/mcp 0.20.6 → 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.
|
|
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.
|
|
37
|
+
"@mandujs/core": "^0.31.0",
|
|
38
38
|
"@mandujs/ate": "^0.19.1",
|
|
39
|
-
"@mandujs/skills": "^
|
|
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
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
+
}
|