@mandujs/mcp 0.38.0 → 0.38.2
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 +2 -2
- package/src/tools/guard.ts +162 -162
- package/src/tools/hydration.ts +58 -28
- package/src/tools/index.ts +69 -69
- package/src/tools/runtime.ts +253 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mandujs/mcp",
|
|
3
|
-
"version": "0.38.
|
|
3
|
+
"version": "0.38.2",
|
|
4
4
|
"description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"access": "public"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@mandujs/core": "^0.54.
|
|
37
|
+
"@mandujs/core": "^0.54.8",
|
|
38
38
|
"@mandujs/ate": "^0.26.1",
|
|
39
39
|
"@mandujs/skills": "^0.20.1",
|
|
40
40
|
"@modelcontextprotocol/sdk": "^1.25.3"
|
package/src/tools/guard.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import { type ManduError } from "@mandujs/core/error";
|
|
3
|
-
import {
|
|
4
|
-
checkDirectory,
|
|
5
|
-
getDefaultFsRoutesGuardPolicy,
|
|
6
|
-
validateAndReport,
|
|
7
|
-
loadManifest,
|
|
8
|
-
runGuardCheck,
|
|
9
|
-
runAutoCorrect,
|
|
10
|
-
type GeneratedMap,
|
|
3
|
+
import {
|
|
4
|
+
checkDirectory,
|
|
5
|
+
getDefaultFsRoutesGuardPolicy,
|
|
6
|
+
validateAndReport,
|
|
7
|
+
loadManifest,
|
|
8
|
+
runGuardCheck,
|
|
9
|
+
runAutoCorrect,
|
|
10
|
+
type GeneratedMap,
|
|
11
11
|
// Self-Healing Guard imports
|
|
12
12
|
checkWithHealing,
|
|
13
13
|
healAll,
|
|
@@ -15,19 +15,19 @@ import {
|
|
|
15
15
|
// Follow-up E — type-aware lint bridge
|
|
16
16
|
runTsgolint,
|
|
17
17
|
type GuardConfig,
|
|
18
|
-
type ViolationType,
|
|
19
|
-
type GuardPreset,
|
|
20
|
-
type Violation,
|
|
21
|
-
} from "@mandujs/core";
|
|
22
|
-
import { getProjectPaths, readJsonFile, readConfig } from "../utils/project.js";
|
|
23
|
-
import fs from "fs/promises";
|
|
24
|
-
import path from "path";
|
|
18
|
+
type ViolationType,
|
|
19
|
+
type GuardPreset,
|
|
20
|
+
type Violation,
|
|
21
|
+
} from "@mandujs/core";
|
|
22
|
+
import { getProjectPaths, readJsonFile, readConfig } from "../utils/project.js";
|
|
23
|
+
import fs from "fs/promises";
|
|
24
|
+
import path from "path";
|
|
25
25
|
|
|
26
26
|
export const guardToolDefinitions: Tool[] = [
|
|
27
27
|
{
|
|
28
|
-
name: "mandu.guard.check",
|
|
29
|
-
description:
|
|
30
|
-
"Run the same architecture guard used by `mandu guard`, plus legacy spec/generated/slot checks. Set typeAware=true to additionally run `oxlint --type-aware` (tsgolint) and merge its results.",
|
|
28
|
+
name: "mandu.guard.check",
|
|
29
|
+
description:
|
|
30
|
+
"Run the same architecture guard used by `mandu guard`, plus legacy spec/generated/slot checks. Set typeAware=true to additionally run `oxlint --type-aware` (tsgolint) and merge its results.",
|
|
31
31
|
annotations: {
|
|
32
32
|
readOnlyHint: true,
|
|
33
33
|
},
|
|
@@ -126,37 +126,37 @@ export const guardToolDefinitions: Tool[] = [
|
|
|
126
126
|
},
|
|
127
127
|
];
|
|
128
128
|
|
|
129
|
-
export function guardTools(projectRoot: string) {
|
|
130
|
-
const paths = getProjectPaths(projectRoot);
|
|
131
|
-
|
|
132
|
-
const pathExists = async (candidate: string): Promise<boolean> => {
|
|
133
|
-
try {
|
|
134
|
-
await fs.access(candidate);
|
|
135
|
-
return true;
|
|
136
|
-
} catch {
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const summarizeArchitectureViolation = (violation: Violation) => ({
|
|
142
|
-
ruleId: violation.ruleName,
|
|
143
|
-
type: violation.type,
|
|
144
|
-
file: path.relative(projectRoot, violation.filePath).replace(/\\/g, "/") || violation.filePath,
|
|
145
|
-
line: violation.line,
|
|
146
|
-
column: violation.column,
|
|
147
|
-
message: violation.ruleDescription,
|
|
148
|
-
suggestion: violation.suggestions[0],
|
|
149
|
-
fromLayer: violation.fromLayer,
|
|
150
|
-
toLayer: violation.toLayer,
|
|
151
|
-
importStatement: violation.importStatement,
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
const summarizeLegacyViolation = (v: Awaited<ReturnType<typeof runGuardCheck>>["violations"][number]) => ({
|
|
155
|
-
ruleId: v.ruleId,
|
|
156
|
-
file: v.file,
|
|
157
|
-
message: v.message,
|
|
158
|
-
suggestion: v.suggestion,
|
|
159
|
-
});
|
|
129
|
+
export function guardTools(projectRoot: string) {
|
|
130
|
+
const paths = getProjectPaths(projectRoot);
|
|
131
|
+
|
|
132
|
+
const pathExists = async (candidate: string): Promise<boolean> => {
|
|
133
|
+
try {
|
|
134
|
+
await fs.access(candidate);
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const summarizeArchitectureViolation = (violation: Violation) => ({
|
|
142
|
+
ruleId: violation.ruleName,
|
|
143
|
+
type: violation.type,
|
|
144
|
+
file: path.relative(projectRoot, violation.filePath).replace(/\\/g, "/") || violation.filePath,
|
|
145
|
+
line: violation.line,
|
|
146
|
+
column: violation.column,
|
|
147
|
+
message: violation.ruleDescription,
|
|
148
|
+
suggestion: violation.suggestions[0],
|
|
149
|
+
fromLayer: violation.fromLayer,
|
|
150
|
+
toLayer: violation.toLayer,
|
|
151
|
+
importStatement: violation.importStatement,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const summarizeLegacyViolation = (v: Awaited<ReturnType<typeof runGuardCheck>>["violations"][number]) => ({
|
|
155
|
+
ruleId: v.ruleId,
|
|
156
|
+
file: v.file,
|
|
157
|
+
message: v.message,
|
|
158
|
+
suggestion: v.suggestion,
|
|
159
|
+
});
|
|
160
160
|
|
|
161
161
|
const handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
|
|
162
162
|
"mandu.guard.check": async (args: Record<string, unknown>) => {
|
|
@@ -172,44 +172,44 @@ export function guardTools(projectRoot: string) {
|
|
|
172
172
|
error: "Failed to load manifest",
|
|
173
173
|
details: manifestResult.errors,
|
|
174
174
|
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const projectConfig = await validateAndReport(projectRoot);
|
|
178
|
-
const guardConfigFromFile = (projectConfig?.guard ?? {}) as GuardConfig;
|
|
179
|
-
const preset = guardConfigFromFile.preset ?? "mandu";
|
|
180
|
-
const enableFsRoutes = await pathExists(paths.appDir);
|
|
181
|
-
|
|
182
|
-
const architectureReport = await checkDirectory(
|
|
183
|
-
{
|
|
184
|
-
preset,
|
|
185
|
-
srcDir: guardConfigFromFile.srcDir ?? "src",
|
|
186
|
-
exclude: guardConfigFromFile.exclude,
|
|
187
|
-
fsRoutes: getDefaultFsRoutesGuardPolicy(enableFsRoutes),
|
|
188
|
-
},
|
|
189
|
-
projectRoot
|
|
190
|
-
);
|
|
191
|
-
const architecturePassed = architectureReport.bySeverity.error === 0;
|
|
192
|
-
const architectureViolations = architectureReport.violations.map(
|
|
193
|
-
summarizeArchitectureViolation
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
// Run guard check
|
|
197
|
-
const checkResult = await runGuardCheck(manifestResult.data, projectRoot);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const projectConfig = await validateAndReport(projectRoot);
|
|
178
|
+
const guardConfigFromFile = (projectConfig?.guard ?? {}) as GuardConfig;
|
|
179
|
+
const preset = guardConfigFromFile.preset ?? "mandu";
|
|
180
|
+
const enableFsRoutes = await pathExists(paths.appDir);
|
|
181
|
+
|
|
182
|
+
const architectureReport = await checkDirectory(
|
|
183
|
+
{
|
|
184
|
+
preset,
|
|
185
|
+
srcDir: guardConfigFromFile.srcDir ?? "src",
|
|
186
|
+
exclude: guardConfigFromFile.exclude,
|
|
187
|
+
fsRoutes: getDefaultFsRoutesGuardPolicy(enableFsRoutes),
|
|
188
|
+
},
|
|
189
|
+
projectRoot
|
|
190
|
+
);
|
|
191
|
+
const architecturePassed = architectureReport.bySeverity.error === 0;
|
|
192
|
+
const architectureViolations = architectureReport.violations.map(
|
|
193
|
+
summarizeArchitectureViolation
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// Run guard check
|
|
197
|
+
const checkResult = await runGuardCheck(manifestResult.data, projectRoot);
|
|
198
198
|
|
|
199
199
|
// Follow-up E — resolve type-aware defaulting from config, then
|
|
200
200
|
// run the bridge when enabled. Result envelope is always included
|
|
201
201
|
// in the tool response so MCP clients have a single stable shape.
|
|
202
|
-
let rawProjectConfig: Awaited<ReturnType<typeof readConfig>> | undefined;
|
|
203
|
-
try {
|
|
204
|
-
rawProjectConfig = projectConfig ?? await readConfig(projectRoot);
|
|
205
|
-
} catch {
|
|
206
|
-
rawProjectConfig = projectConfig ?? undefined;
|
|
207
|
-
}
|
|
208
|
-
const typeAwareCfg = (
|
|
209
|
-
rawProjectConfig?.guard as
|
|
210
|
-
| { typeAware?: Record<string, unknown> }
|
|
211
|
-
| undefined
|
|
212
|
-
)?.typeAware;
|
|
202
|
+
let rawProjectConfig: Awaited<ReturnType<typeof readConfig>> | undefined;
|
|
203
|
+
try {
|
|
204
|
+
rawProjectConfig = projectConfig ?? await readConfig(projectRoot);
|
|
205
|
+
} catch {
|
|
206
|
+
rawProjectConfig = projectConfig ?? undefined;
|
|
207
|
+
}
|
|
208
|
+
const typeAwareCfg = (
|
|
209
|
+
rawProjectConfig?.guard as
|
|
210
|
+
| { typeAware?: Record<string, unknown> }
|
|
211
|
+
| undefined
|
|
212
|
+
)?.typeAware;
|
|
213
213
|
const typeAwareEnabled =
|
|
214
214
|
typeAwareArg !== undefined ? typeAwareArg : typeAwareCfg !== undefined;
|
|
215
215
|
|
|
@@ -229,40 +229,40 @@ export function guardTools(projectRoot: string) {
|
|
|
229
229
|
skipped: bridge.skipped,
|
|
230
230
|
summary: bridge.summary,
|
|
231
231
|
violations: bridge.violations,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const typeAwareViolations = typeAwareResponse
|
|
236
|
-
? typeAwareResponse.violations as Array<{ severity: string }>
|
|
237
|
-
: [];
|
|
238
|
-
const typeAwareErrorCount = typeAwareViolations.filter(
|
|
239
|
-
(v) => v.severity === "error",
|
|
240
|
-
).length;
|
|
241
|
-
const legacyViolations = checkResult.violations.map(summarizeLegacyViolation);
|
|
242
|
-
const combinedViolations = [
|
|
243
|
-
...architectureViolations,
|
|
244
|
-
...legacyViolations,
|
|
245
|
-
];
|
|
246
|
-
const allPassed = checkResult.passed && architecturePassed && typeAwareErrorCount === 0;
|
|
247
|
-
const blockingViolationCount = combinedViolations.length + typeAwareErrorCount;
|
|
248
|
-
|
|
249
|
-
if (allPassed) {
|
|
250
|
-
return {
|
|
251
|
-
passed: true,
|
|
252
|
-
violations: [],
|
|
253
|
-
message: "All guard checks passed",
|
|
254
|
-
architecture: {
|
|
255
|
-
passed: true,
|
|
256
|
-
totalViolations: architectureReport.totalViolations,
|
|
257
|
-
bySeverity: architectureReport.bySeverity,
|
|
258
|
-
},
|
|
259
|
-
legacy: {
|
|
260
|
-
passed: true,
|
|
261
|
-
violations: 0,
|
|
262
|
-
},
|
|
263
|
-
relatedSkills: ["mandu-guard-guide", "mandu-debug"],
|
|
264
|
-
...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
|
|
265
|
-
};
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const typeAwareViolations = typeAwareResponse
|
|
236
|
+
? typeAwareResponse.violations as Array<{ severity: string }>
|
|
237
|
+
: [];
|
|
238
|
+
const typeAwareErrorCount = typeAwareViolations.filter(
|
|
239
|
+
(v) => v.severity === "error",
|
|
240
|
+
).length;
|
|
241
|
+
const legacyViolations = checkResult.violations.map(summarizeLegacyViolation);
|
|
242
|
+
const combinedViolations = [
|
|
243
|
+
...architectureViolations,
|
|
244
|
+
...legacyViolations,
|
|
245
|
+
];
|
|
246
|
+
const allPassed = checkResult.passed && architecturePassed && typeAwareErrorCount === 0;
|
|
247
|
+
const blockingViolationCount = combinedViolations.length + typeAwareErrorCount;
|
|
248
|
+
|
|
249
|
+
if (allPassed) {
|
|
250
|
+
return {
|
|
251
|
+
passed: true,
|
|
252
|
+
violations: [],
|
|
253
|
+
message: "All guard checks passed",
|
|
254
|
+
architecture: {
|
|
255
|
+
passed: true,
|
|
256
|
+
totalViolations: architectureReport.totalViolations,
|
|
257
|
+
bySeverity: architectureReport.bySeverity,
|
|
258
|
+
},
|
|
259
|
+
legacy: {
|
|
260
|
+
passed: true,
|
|
261
|
+
violations: 0,
|
|
262
|
+
},
|
|
263
|
+
relatedSkills: ["mandu-guard-guide", "mandu-debug"],
|
|
264
|
+
...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
|
|
265
|
+
};
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
// If auto-correct requested and there are violations
|
|
@@ -273,54 +273,54 @@ export function guardTools(projectRoot: string) {
|
|
|
273
273
|
projectRoot
|
|
274
274
|
);
|
|
275
275
|
|
|
276
|
-
return {
|
|
277
|
-
passed:
|
|
278
|
-
autoCorrectResult.fixed &&
|
|
279
|
-
architecturePassed &&
|
|
280
|
-
typeAwareErrorCount === 0,
|
|
281
|
-
violations: [
|
|
282
|
-
...architectureViolations,
|
|
283
|
-
...autoCorrectResult.remainingViolations.map(summarizeLegacyViolation),
|
|
284
|
-
],
|
|
285
|
-
autoCorrect: {
|
|
286
|
-
attempted: true,
|
|
287
|
-
fixed: autoCorrectResult.fixed,
|
|
276
|
+
return {
|
|
277
|
+
passed:
|
|
278
|
+
autoCorrectResult.fixed &&
|
|
279
|
+
architecturePassed &&
|
|
280
|
+
typeAwareErrorCount === 0,
|
|
281
|
+
violations: [
|
|
282
|
+
...architectureViolations,
|
|
283
|
+
...autoCorrectResult.remainingViolations.map(summarizeLegacyViolation),
|
|
284
|
+
],
|
|
285
|
+
autoCorrect: {
|
|
286
|
+
attempted: true,
|
|
287
|
+
fixed: autoCorrectResult.fixed,
|
|
288
288
|
steps: autoCorrectResult.steps,
|
|
289
289
|
retriedCount: autoCorrectResult.retriedCount,
|
|
290
290
|
rolledBack: autoCorrectResult.rolledBack,
|
|
291
|
-
changeId: autoCorrectResult.changeId,
|
|
292
|
-
},
|
|
293
|
-
architecture: {
|
|
294
|
-
passed: architecturePassed,
|
|
295
|
-
totalViolations: architectureReport.totalViolations,
|
|
296
|
-
bySeverity: architectureReport.bySeverity,
|
|
297
|
-
violations: architectureViolations,
|
|
298
|
-
},
|
|
299
|
-
legacy: {
|
|
300
|
-
passed: autoCorrectResult.fixed,
|
|
301
|
-
violations: autoCorrectResult.remainingViolations.length,
|
|
302
|
-
},
|
|
303
|
-
...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
return {
|
|
308
|
-
passed: false,
|
|
309
|
-
violations: combinedViolations,
|
|
310
|
-
message: `Found ${blockingViolationCount} violation(s)`,
|
|
311
|
-
architecture: {
|
|
312
|
-
passed: architecturePassed,
|
|
313
|
-
totalViolations: architectureReport.totalViolations,
|
|
314
|
-
bySeverity: architectureReport.bySeverity,
|
|
315
|
-
violations: architectureViolations,
|
|
316
|
-
},
|
|
317
|
-
legacy: {
|
|
318
|
-
passed: checkResult.passed,
|
|
319
|
-
violations: legacyViolations.length,
|
|
320
|
-
},
|
|
321
|
-
tip: "Use autoCorrect: true to attempt automatic fixes",
|
|
322
|
-
relatedSkills: ["mandu-guard-guide", "mandu-debug"],
|
|
323
|
-
...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
|
|
291
|
+
changeId: autoCorrectResult.changeId,
|
|
292
|
+
},
|
|
293
|
+
architecture: {
|
|
294
|
+
passed: architecturePassed,
|
|
295
|
+
totalViolations: architectureReport.totalViolations,
|
|
296
|
+
bySeverity: architectureReport.bySeverity,
|
|
297
|
+
violations: architectureViolations,
|
|
298
|
+
},
|
|
299
|
+
legacy: {
|
|
300
|
+
passed: autoCorrectResult.fixed,
|
|
301
|
+
violations: autoCorrectResult.remainingViolations.length,
|
|
302
|
+
},
|
|
303
|
+
...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
passed: false,
|
|
309
|
+
violations: combinedViolations,
|
|
310
|
+
message: `Found ${blockingViolationCount} violation(s)`,
|
|
311
|
+
architecture: {
|
|
312
|
+
passed: architecturePassed,
|
|
313
|
+
totalViolations: architectureReport.totalViolations,
|
|
314
|
+
bySeverity: architectureReport.bySeverity,
|
|
315
|
+
violations: architectureViolations,
|
|
316
|
+
},
|
|
317
|
+
legacy: {
|
|
318
|
+
passed: checkResult.passed,
|
|
319
|
+
violations: legacyViolations.length,
|
|
320
|
+
},
|
|
321
|
+
tip: "Use autoCorrect: true to attempt automatic fixes",
|
|
322
|
+
relatedSkills: ["mandu-guard-guide", "mandu-debug"],
|
|
323
|
+
...(typeAwareResponse ? { typeAware: typeAwareResponse } : {}),
|
|
324
324
|
};
|
|
325
325
|
},
|
|
326
326
|
|
package/src/tools/hydration.ts
CHANGED
|
@@ -61,8 +61,22 @@ export const hydrationToolDefinitions: Tool[] = [
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
name: "mandu.island.list",
|
|
64
|
-
description:
|
|
65
|
-
"
|
|
64
|
+
description:
|
|
65
|
+
"Legacy alias for page client mount diagnostics. Prefer mandu.pageClientMount.list for terminology that separates page-level hydration from nested islands.",
|
|
66
|
+
annotations: {
|
|
67
|
+
readOnlyHint: true,
|
|
68
|
+
},
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {},
|
|
72
|
+
required: [],
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "mandu.pageClientMount.list",
|
|
78
|
+
description:
|
|
79
|
+
"List page-level client mounts: page routes that need a route-level clientModule and bundle. This is distinct from nested islands/partials.",
|
|
66
80
|
annotations: {
|
|
67
81
|
readOnlyHint: true,
|
|
68
82
|
},
|
|
@@ -220,19 +234,19 @@ export function hydrationTools(projectRoot: string) {
|
|
|
220
234
|
};
|
|
221
235
|
},
|
|
222
236
|
|
|
223
|
-
"mandu.
|
|
224
|
-
// Load manifest
|
|
225
|
-
const manifestResult = await loadManifest(paths.manifestPath);
|
|
226
|
-
if (!manifestResult.success || !manifestResult.data) {
|
|
227
|
-
return { error: manifestResult.errors };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const
|
|
237
|
+
"mandu.pageClientMount.list": async () => {
|
|
238
|
+
// Load manifest
|
|
239
|
+
const manifestResult = await loadManifest(paths.manifestPath);
|
|
240
|
+
if (!manifestResult.success || !manifestResult.data) {
|
|
241
|
+
return { error: manifestResult.errors };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const pageClientMounts = manifestResult.data.routes
|
|
231
245
|
.filter((route) => route.kind === "page")
|
|
232
246
|
.map((route) => {
|
|
233
247
|
const hydration = getRouteHydration(route);
|
|
234
|
-
const
|
|
235
|
-
const warning =
|
|
248
|
+
const needsClientMount = needsHydration(route);
|
|
249
|
+
const warning = needsClientMount && !route.clientModule
|
|
236
250
|
? `Route has hydration strategy '${hydration.strategy}' but no clientModule; build would emit no route bundle.`
|
|
237
251
|
: null;
|
|
238
252
|
|
|
@@ -241,29 +255,44 @@ export function hydrationTools(projectRoot: string) {
|
|
|
241
255
|
pattern: route.pattern,
|
|
242
256
|
hasClientModule: !!route.clientModule,
|
|
243
257
|
clientModule: route.clientModule || null,
|
|
244
|
-
|
|
245
|
-
|
|
258
|
+
needsClientMount,
|
|
259
|
+
// Backward compatibility for older agents that read `isIsland`.
|
|
260
|
+
isIsland: needsClientMount,
|
|
261
|
+
status: needsClientMount ? (route.clientModule ? "ready" : "broken") : "static",
|
|
246
262
|
warning,
|
|
247
263
|
hydration: {
|
|
248
264
|
strategy: hydration.strategy,
|
|
249
265
|
priority: hydration.priority,
|
|
250
266
|
preload: hydration.preload,
|
|
251
|
-
},
|
|
252
|
-
};
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
const staticCount =
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const pageClientMountCount = pageClientMounts.filter((i) => i.needsClientMount).length;
|
|
272
|
+
const staticCount = pageClientMounts.filter((i) => !i.needsClientMount).length;
|
|
273
|
+
const activeMounts = pageClientMounts.filter((i) => i.needsClientMount);
|
|
274
|
+
const staticPages = pageClientMounts.filter((i) => !i.needsClientMount);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
terminology: {
|
|
278
|
+
pageClientMount:
|
|
279
|
+
"A page route whose whole page is hydrated from a route-level clientModule and route bundle.",
|
|
280
|
+
island:
|
|
281
|
+
"A nested or route-local island bundle, distinct from page client mounts. Use mandu.runtime.status for both collections.",
|
|
282
|
+
},
|
|
283
|
+
totalPages: pageClientMounts.length,
|
|
284
|
+
pageClientMountCount,
|
|
285
|
+
staticCount,
|
|
286
|
+
pageClientMounts: activeMounts,
|
|
287
|
+
staticPages,
|
|
288
|
+
// Backward compatibility for older clients.
|
|
289
|
+
islandCount: pageClientMountCount,
|
|
290
|
+
islands: activeMounts,
|
|
291
|
+
};
|
|
265
292
|
},
|
|
266
293
|
|
|
294
|
+
"mandu.island.list": async () => handlers["mandu.pageClientMount.list"]({}),
|
|
295
|
+
|
|
267
296
|
"mandu.hydration.set": async (args: Record<string, unknown>) => {
|
|
268
297
|
const validationError = validateRouteIdArgs(
|
|
269
298
|
args,
|
|
@@ -423,6 +452,7 @@ export function hydrationTools(projectRoot: string) {
|
|
|
423
452
|
handlers["mandu_build"] = handlers["mandu.build"];
|
|
424
453
|
handlers["mandu_build_status"] = handlers["mandu.build.status"];
|
|
425
454
|
handlers["mandu_list_islands"] = handlers["mandu.island.list"];
|
|
455
|
+
handlers["mandu_page_client_mount_list"] = handlers["mandu.pageClientMount.list"];
|
|
426
456
|
handlers["mandu_set_hydration"] = handlers["mandu.hydration.set"];
|
|
427
457
|
handlers["mandu_hydration_set"] = handlers["mandu.hydration.set"];
|
|
428
458
|
handlers["mandu_add_client_slot"] = handlers["mandu.hydration.addClientSlot"];
|
package/src/tools/index.ts
CHANGED
|
@@ -11,9 +11,9 @@ import { mcpToolRegistry } from "../registry/mcp-tool-registry.js";
|
|
|
11
11
|
import { moduleToPlugins } from "../adapters/tool-adapter.js";
|
|
12
12
|
import { type McpProfile, getProfileCategories } from "../profiles.js";
|
|
13
13
|
|
|
14
|
-
// 도구 모듈 export
|
|
15
|
-
export { agentTools, agentToolDefinitions } from "./agent.js";
|
|
16
|
-
export { specTools, specToolDefinitions } from "./spec.js";
|
|
14
|
+
// 도구 모듈 export
|
|
15
|
+
export { agentTools, agentToolDefinitions } from "./agent.js";
|
|
16
|
+
export { specTools, specToolDefinitions } from "./spec.js";
|
|
17
17
|
export { generateTools, generateToolDefinitions } from "./generate.js";
|
|
18
18
|
export { transactionTools, transactionToolDefinitions } from "./transaction.js";
|
|
19
19
|
export { historyTools, historyToolDefinitions } from "./history.js";
|
|
@@ -86,9 +86,9 @@ export {
|
|
|
86
86
|
extractContractToolDefinitions,
|
|
87
87
|
} from "./extract-contract.js";
|
|
88
88
|
|
|
89
|
-
// 도구 모듈 import (등록용)
|
|
90
|
-
import { agentTools, agentToolDefinitions } from "./agent.js";
|
|
91
|
-
import { specTools, specToolDefinitions } from "./spec.js";
|
|
89
|
+
// 도구 모듈 import (등록용)
|
|
90
|
+
import { agentTools, agentToolDefinitions } from "./agent.js";
|
|
91
|
+
import { specTools, specToolDefinitions } from "./spec.js";
|
|
92
92
|
import { generateTools, generateToolDefinitions } from "./generate.js";
|
|
93
93
|
import { transactionTools, transactionToolDefinitions } from "./transaction.js";
|
|
94
94
|
import { historyTools, historyToolDefinitions } from "./history.js";
|
|
@@ -164,10 +164,10 @@ import {
|
|
|
164
164
|
/**
|
|
165
165
|
* 도구 모듈 정보
|
|
166
166
|
*/
|
|
167
|
-
export interface ToolModule {
|
|
168
|
-
category: string;
|
|
169
|
-
definitions: Tool[];
|
|
170
|
-
handlers: (
|
|
167
|
+
export interface ToolModule {
|
|
168
|
+
category: string;
|
|
169
|
+
definitions: Tool[];
|
|
170
|
+
handlers: (
|
|
171
171
|
projectRoot: string,
|
|
172
172
|
server?: Server,
|
|
173
173
|
monitor?: ActivityMonitor
|
|
@@ -190,9 +190,9 @@ export interface ToolModule {
|
|
|
190
190
|
/**
|
|
191
191
|
* 빌트인 도구 모듈 목록
|
|
192
192
|
*/
|
|
193
|
-
export const TOOL_MODULES: ToolModule[] = [
|
|
194
|
-
{ category: "agent", definitions: agentToolDefinitions, handlers: agentTools },
|
|
195
|
-
{ category: "spec", definitions: specToolDefinitions, handlers: specTools },
|
|
193
|
+
export const TOOL_MODULES: ToolModule[] = [
|
|
194
|
+
{ category: "agent", definitions: agentToolDefinitions, handlers: agentTools },
|
|
195
|
+
{ category: "spec", definitions: specToolDefinitions, handlers: specTools },
|
|
196
196
|
{ category: "generate", definitions: generateToolDefinitions, handlers: generateTools },
|
|
197
197
|
{ category: "transaction", definitions: transactionToolDefinitions, handlers: transactionTools },
|
|
198
198
|
{ category: "history", definitions: historyToolDefinitions, handlers: historyTools },
|
|
@@ -203,18 +203,18 @@ export const TOOL_MODULES: ToolModule[] = [
|
|
|
203
203
|
{ category: "slot", definitions: slotToolDefinitions, handlers: slotTools },
|
|
204
204
|
{ category: "hydration", definitions: hydrationToolDefinitions, handlers: hydrationTools },
|
|
205
205
|
{ category: "contract", definitions: contractToolDefinitions, handlers: contractTools },
|
|
206
|
-
{ category: "brain", definitions: brainToolDefinitions, handlers: brainTools, requiresServer: true },
|
|
206
|
+
{ category: "brain", definitions: brainToolDefinitions, handlers: brainTools, requiresServer: true },
|
|
207
207
|
{ category: "runtime", definitions: runtimeToolDefinitions, handlers: runtimeTools },
|
|
208
208
|
{ category: "seo", definitions: seoToolDefinitions, handlers: seoTools },
|
|
209
|
-
{ category: "project", definitions: projectToolDefinitions, handlers: projectTools, requiresServer: true },
|
|
209
|
+
{ category: "project", definitions: projectToolDefinitions, handlers: projectTools, requiresServer: true },
|
|
210
210
|
// ate + ate-run accept an optional Server so notifications/progress
|
|
211
211
|
// can flow (issue #238). `acceptsServer: true` forwards the server
|
|
212
212
|
// when available but still registers when it isn't — callers that
|
|
213
213
|
// boot without an MCP transport get progress no-oped silently.
|
|
214
|
-
{ category: "ate", definitions: ateToolDefinitions, handlers: ateTools, acceptsServer: true },
|
|
215
|
-
{ category: "ate-phase5", definitions: atePhase5ToolDefinitions, handlers: createAtePhase5Handlers },
|
|
214
|
+
{ category: "ate", definitions: ateToolDefinitions, handlers: ateTools, acceptsServer: true },
|
|
215
|
+
{ category: "ate-phase5", definitions: atePhase5ToolDefinitions, handlers: createAtePhase5Handlers },
|
|
216
216
|
{ category: "ate-context", definitions: ateContextToolDefinitions, handlers: ateContextTools },
|
|
217
|
-
{ category: "ate-run", definitions: ateRunToolDefinitions, handlers: ateRunTools, acceptsServer: true },
|
|
217
|
+
{ category: "ate-run", definitions: ateRunToolDefinitions, handlers: ateRunTools, acceptsServer: true },
|
|
218
218
|
{ category: "ate-flakes", definitions: ateFlakesToolDefinitions, handlers: ateFlakesTools },
|
|
219
219
|
{ category: "ate-prompt", definitions: atePromptToolDefinitions, handlers: atePromptTools },
|
|
220
220
|
{ category: "ate-exemplar", definitions: ateExemplarToolDefinitions, handlers: ateExemplarTools },
|
|
@@ -285,41 +285,41 @@ export const TOOL_MODULES: ToolModule[] = [
|
|
|
285
285
|
definitions: extractContractToolDefinitions,
|
|
286
286
|
handlers: extractContractTools,
|
|
287
287
|
},
|
|
288
|
-
];
|
|
289
|
-
|
|
290
|
-
export function validateBuiltinToolModules(
|
|
291
|
-
modules: readonly ToolModule[] = TOOL_MODULES
|
|
292
|
-
): string[] {
|
|
293
|
-
const issues: string[] = [];
|
|
294
|
-
const categories = new Set<string>();
|
|
295
|
-
const toolNames = new Map<string, string>();
|
|
296
|
-
|
|
297
|
-
for (const module of modules) {
|
|
298
|
-
if (categories.has(module.category)) {
|
|
299
|
-
issues.push(`duplicate tool category: ${module.category}`);
|
|
300
|
-
}
|
|
301
|
-
categories.add(module.category);
|
|
302
|
-
|
|
303
|
-
if (module.definitions.length === 0) {
|
|
304
|
-
issues.push(`tool category has no definitions: ${module.category}`);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
for (const definition of module.definitions) {
|
|
308
|
-
const previousCategory = toolNames.get(definition.name);
|
|
309
|
-
if (previousCategory) {
|
|
310
|
-
issues.push(
|
|
311
|
-
`duplicate tool definition: ${definition.name} in ${previousCategory} and ${module.category}`
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
toolNames.set(definition.name, module.category);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return issues;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/**
|
|
322
|
-
* 빌트인 도구들을 레지스트리에 등록
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
export function validateBuiltinToolModules(
|
|
291
|
+
modules: readonly ToolModule[] = TOOL_MODULES
|
|
292
|
+
): string[] {
|
|
293
|
+
const issues: string[] = [];
|
|
294
|
+
const categories = new Set<string>();
|
|
295
|
+
const toolNames = new Map<string, string>();
|
|
296
|
+
|
|
297
|
+
for (const module of modules) {
|
|
298
|
+
if (categories.has(module.category)) {
|
|
299
|
+
issues.push(`duplicate tool category: ${module.category}`);
|
|
300
|
+
}
|
|
301
|
+
categories.add(module.category);
|
|
302
|
+
|
|
303
|
+
if (module.definitions.length === 0) {
|
|
304
|
+
issues.push(`tool category has no definitions: ${module.category}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
for (const definition of module.definitions) {
|
|
308
|
+
const previousCategory = toolNames.get(definition.name);
|
|
309
|
+
if (previousCategory) {
|
|
310
|
+
issues.push(
|
|
311
|
+
`duplicate tool definition: ${definition.name} in ${previousCategory} and ${module.category}`
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
toolNames.set(definition.name, module.category);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return issues;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 빌트인 도구들을 레지스트리에 등록
|
|
323
323
|
*
|
|
324
324
|
* @param projectRoot - 프로젝트 루트 경로
|
|
325
325
|
* @param server - MCP Server 인스턴스 (선택, brain/project 도구에 필요)
|
|
@@ -340,15 +340,15 @@ export function registerBuiltinTools(
|
|
|
340
340
|
monitor?: ActivityMonitor,
|
|
341
341
|
options?: { profile?: McpProfile }
|
|
342
342
|
): void {
|
|
343
|
-
const allowedCategories = options?.profile
|
|
344
|
-
? getProfileCategories(options.profile)
|
|
345
|
-
: null;
|
|
346
|
-
const definitionIssues = validateBuiltinToolModules();
|
|
347
|
-
if (definitionIssues.length > 0) {
|
|
348
|
-
throw new Error(`Invalid MCP tool module registry:\n${definitionIssues.join("\n")}`);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
for (const module of TOOL_MODULES) {
|
|
343
|
+
const allowedCategories = options?.profile
|
|
344
|
+
? getProfileCategories(options.profile)
|
|
345
|
+
: null;
|
|
346
|
+
const definitionIssues = validateBuiltinToolModules();
|
|
347
|
+
if (definitionIssues.length > 0) {
|
|
348
|
+
throw new Error(`Invalid MCP tool module registry:\n${definitionIssues.join("\n")}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const module of TOOL_MODULES) {
|
|
352
352
|
// Profile filtering: skip categories not in the allowed list
|
|
353
353
|
if (allowedCategories && !allowedCategories.includes(module.category)) {
|
|
354
354
|
continue;
|
|
@@ -359,13 +359,13 @@ export function registerBuiltinTools(
|
|
|
359
359
|
continue;
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
try {
|
|
363
|
-
let handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>>;
|
|
364
|
-
if (module.requiresServer) {
|
|
365
|
-
handlers = module.handlers(projectRoot, server, monitor);
|
|
366
|
-
} else if (module.acceptsServer) {
|
|
367
|
-
// Forward the Server when available; fall back to just projectRoot.
|
|
368
|
-
handlers = server
|
|
362
|
+
try {
|
|
363
|
+
let handlers: Record<string, (args: Record<string, unknown>) => Promise<unknown>>;
|
|
364
|
+
if (module.requiresServer) {
|
|
365
|
+
handlers = module.handlers(projectRoot, server, monitor);
|
|
366
|
+
} else if (module.acceptsServer) {
|
|
367
|
+
// Forward the Server when available; fall back to just projectRoot.
|
|
368
|
+
handlers = server
|
|
369
369
|
? module.handlers(projectRoot, server)
|
|
370
370
|
: module.handlers(projectRoot);
|
|
371
371
|
} else {
|
package/src/tools/runtime.ts
CHANGED
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
7
7
|
import { getProjectPaths } from "../utils/project.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getRouteHydration,
|
|
10
|
+
loadManduConfig,
|
|
11
|
+
loadManifest,
|
|
12
|
+
needsHydration,
|
|
13
|
+
type BundleManifest,
|
|
14
|
+
} from "@mandujs/core";
|
|
9
15
|
import { getDevServerState } from "./project.js";
|
|
10
16
|
import { readRuntimeControl } from "../utils/runtime-control.js";
|
|
11
17
|
import path from "path";
|
|
@@ -69,6 +75,20 @@ export const runtimeToolDefinitions: Tool[] = [
|
|
|
69
75
|
additionalProperties: false,
|
|
70
76
|
},
|
|
71
77
|
},
|
|
78
|
+
{
|
|
79
|
+
name: "mandu.runtime.status",
|
|
80
|
+
annotations: {
|
|
81
|
+
readOnlyHint: true,
|
|
82
|
+
},
|
|
83
|
+
description:
|
|
84
|
+
"Single source of truth for Mandu client runtime state. Separates page client mounts from nested islands and compares routes manifest, bundle manifest, and generated route artifacts.",
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {},
|
|
88
|
+
required: [],
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
72
92
|
{
|
|
73
93
|
name: "mandu.runtime.contractOptions",
|
|
74
94
|
annotations: {
|
|
@@ -330,6 +350,67 @@ export default Mandu.contract({
|
|
|
330
350
|
};
|
|
331
351
|
},
|
|
332
352
|
|
|
353
|
+
"mandu.runtime.status": async () => {
|
|
354
|
+
const result = await loadManifest(paths.manifestPath);
|
|
355
|
+
if (!result.success || !result.data) {
|
|
356
|
+
return {
|
|
357
|
+
success: false,
|
|
358
|
+
error: result.errors,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const bundleManifest = await readBundleManifest(projectRoot);
|
|
363
|
+
const pageRoutes = result.data.routes.filter((route) => route.kind === "page");
|
|
364
|
+
const pageClientMounts = await Promise.all(
|
|
365
|
+
pageRoutes.map(async (route) =>
|
|
366
|
+
describePageClientMount(projectRoot, route, bundleManifest),
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
const nestedIslands = Object.entries(bundleManifest?.islands ?? {}).map(([id, island]) => ({
|
|
370
|
+
islandId: id,
|
|
371
|
+
routeId: island.route,
|
|
372
|
+
bundleUrl: island.js,
|
|
373
|
+
priority: island.priority,
|
|
374
|
+
}));
|
|
375
|
+
const partials = Object.entries(bundleManifest?.partials ?? {}).map(([id, partial]) => ({
|
|
376
|
+
partialId: id,
|
|
377
|
+
bundleUrl: partial.js,
|
|
378
|
+
priority: partial.priority,
|
|
379
|
+
}));
|
|
380
|
+
const consistencyChecks = buildRuntimeConsistencyChecks(pageClientMounts, bundleManifest);
|
|
381
|
+
const failedChecks = consistencyChecks.filter((check) => check.status === "fail");
|
|
382
|
+
const brokenMounts = pageClientMounts.filter((mount) => mount.status === "broken");
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
success: failedChecks.length === 0 && brokenMounts.length === 0,
|
|
386
|
+
terminology: {
|
|
387
|
+
pageClientMount:
|
|
388
|
+
"A page route whose whole page is hydrated from a route-level clientModule and route bundle.",
|
|
389
|
+
island:
|
|
390
|
+
"A nested or route-local island bundle listed in .mandu/manifest.json islands, distinct from page client mounts.",
|
|
391
|
+
partial:
|
|
392
|
+
"An inline partial hydration boundary listed in .mandu/manifest.json partials.",
|
|
393
|
+
},
|
|
394
|
+
sources: {
|
|
395
|
+
routesManifest: ".mandu/routes.manifest.json",
|
|
396
|
+
bundleManifest: bundleManifest ? ".mandu/manifest.json" : null,
|
|
397
|
+
generatedRoutes: ".mandu/generated/web/routes/*.route.tsx",
|
|
398
|
+
},
|
|
399
|
+
summary: {
|
|
400
|
+
totalPages: pageRoutes.length,
|
|
401
|
+
pageClientMountCount: pageClientMounts.filter((mount) => mount.needsClientMount).length,
|
|
402
|
+
brokenPageClientMountCount: brokenMounts.length,
|
|
403
|
+
nestedIslandCount: nestedIslands.length,
|
|
404
|
+
partialCount: partials.length,
|
|
405
|
+
failedConsistencyCheckCount: failedChecks.length,
|
|
406
|
+
},
|
|
407
|
+
pageClientMounts,
|
|
408
|
+
islands: nestedIslands,
|
|
409
|
+
partials,
|
|
410
|
+
consistencyChecks,
|
|
411
|
+
};
|
|
412
|
+
},
|
|
413
|
+
|
|
333
414
|
"mandu.runtime.contractOptions": async (args: Record<string, unknown>) => {
|
|
334
415
|
const { routeId } = args as { routeId: string };
|
|
335
416
|
|
|
@@ -636,10 +717,181 @@ export const appLogger = logger(${JSON.stringify(config, null, 2)});
|
|
|
636
717
|
handlers["mandu_list_logger_options"] = handlers["mandu.runtime.loggerOptions"];
|
|
637
718
|
handlers["mandu_generate_logger_config"] = handlers["mandu.runtime.loggerConfig"];
|
|
638
719
|
handlers["mandu_runtime_probe"] = handlers["mandu.runtime.probe"];
|
|
720
|
+
handlers["mandu_runtime_status"] = handlers["mandu.runtime.status"];
|
|
639
721
|
|
|
640
722
|
return handlers;
|
|
641
723
|
}
|
|
642
724
|
|
|
725
|
+
type PageRouteForStatus = Parameters<typeof needsHydration>[0] & {
|
|
726
|
+
id: string;
|
|
727
|
+
pattern: string;
|
|
728
|
+
clientModule?: string;
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
type PageClientMountStatus = {
|
|
732
|
+
routeId: string;
|
|
733
|
+
pattern: string;
|
|
734
|
+
needsClientMount: boolean;
|
|
735
|
+
hasClientModule: boolean;
|
|
736
|
+
clientModule: string | null;
|
|
737
|
+
hydration: ReturnType<typeof getRouteHydration>;
|
|
738
|
+
bundleUrl: string | null;
|
|
739
|
+
status: "static" | "pending" | "healthy" | "broken";
|
|
740
|
+
reasons: string[];
|
|
741
|
+
generatedRoute: GeneratedRouteInspection;
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
type GeneratedRouteInspection = {
|
|
745
|
+
path: string;
|
|
746
|
+
exists: boolean;
|
|
747
|
+
kind: "missing" | "client_mount" | "placeholder" | "custom";
|
|
748
|
+
referencesClientModule: boolean;
|
|
749
|
+
callsIslandRender: boolean;
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
type RuntimeConsistencyCheck = {
|
|
753
|
+
check: string;
|
|
754
|
+
status: "pass" | "fail" | "skip";
|
|
755
|
+
failingRoutes?: string[];
|
|
756
|
+
reason?: string;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
async function readBundleManifest(projectRoot: string): Promise<BundleManifest | null> {
|
|
760
|
+
const filePath = path.join(projectRoot, ".mandu/manifest.json");
|
|
761
|
+
try {
|
|
762
|
+
const raw = await Bun.file(filePath).text();
|
|
763
|
+
return JSON.parse(raw) as BundleManifest;
|
|
764
|
+
} catch {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function describePageClientMount(
|
|
770
|
+
projectRoot: string,
|
|
771
|
+
route: PageRouteForStatus,
|
|
772
|
+
bundleManifest: BundleManifest | null,
|
|
773
|
+
): Promise<PageClientMountStatus> {
|
|
774
|
+
const hydration = getRouteHydration(route);
|
|
775
|
+
const needsClientMount = needsHydration(route);
|
|
776
|
+
const bundle = bundleManifest?.bundles?.[route.id] ?? null;
|
|
777
|
+
const generatedRoute = await inspectGeneratedRoute(projectRoot, route);
|
|
778
|
+
const reasons: string[] = [];
|
|
779
|
+
|
|
780
|
+
if (needsClientMount && !route.clientModule) {
|
|
781
|
+
reasons.push("missing_client_module");
|
|
782
|
+
}
|
|
783
|
+
if (needsClientMount && route.clientModule && bundleManifest && !bundle) {
|
|
784
|
+
reasons.push("missing_bundle");
|
|
785
|
+
}
|
|
786
|
+
if (needsClientMount && route.clientModule && generatedRoute.exists && !generatedRoute.referencesClientModule) {
|
|
787
|
+
reasons.push("generated_route_not_using_client_module");
|
|
788
|
+
}
|
|
789
|
+
if (needsClientMount && !route.clientModule && generatedRoute.kind === "placeholder") {
|
|
790
|
+
reasons.push("generated_placeholder_for_hydrating_route");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const status = !needsClientMount
|
|
794
|
+
? "static"
|
|
795
|
+
: reasons.length > 0
|
|
796
|
+
? "broken"
|
|
797
|
+
: bundleManifest
|
|
798
|
+
? "healthy"
|
|
799
|
+
: "pending";
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
routeId: route.id,
|
|
803
|
+
pattern: route.pattern,
|
|
804
|
+
needsClientMount,
|
|
805
|
+
hasClientModule: !!route.clientModule,
|
|
806
|
+
clientModule: route.clientModule ?? null,
|
|
807
|
+
hydration,
|
|
808
|
+
bundleUrl: bundle?.js ?? null,
|
|
809
|
+
status,
|
|
810
|
+
reasons,
|
|
811
|
+
generatedRoute,
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
async function inspectGeneratedRoute(
|
|
816
|
+
projectRoot: string,
|
|
817
|
+
route: PageRouteForStatus,
|
|
818
|
+
): Promise<GeneratedRouteInspection> {
|
|
819
|
+
const relPath = `.mandu/generated/web/routes/${route.id}.route.tsx`;
|
|
820
|
+
const filePath = path.join(projectRoot, relPath);
|
|
821
|
+
let source: string;
|
|
822
|
+
try {
|
|
823
|
+
source = await Bun.file(filePath).text();
|
|
824
|
+
} catch {
|
|
825
|
+
return {
|
|
826
|
+
path: relPath,
|
|
827
|
+
exists: false,
|
|
828
|
+
kind: "missing",
|
|
829
|
+
referencesClientModule: false,
|
|
830
|
+
callsIslandRender: false,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const normalized = source.replace(/\\/g, "/");
|
|
835
|
+
const clientModule = route.clientModule?.replace(/\\/g, "/") ?? null;
|
|
836
|
+
const referencesClientModule = clientModule ? normalized.includes(`Client Module: ${clientModule}`) : false;
|
|
837
|
+
const callsIslandRender = normalized.includes("islandModule.definition.render");
|
|
838
|
+
const placeholder =
|
|
839
|
+
normalized.includes('React.createElement("h1", null') &&
|
|
840
|
+
normalized.includes(`Route ID: ${route.id}`);
|
|
841
|
+
|
|
842
|
+
return {
|
|
843
|
+
path: relPath,
|
|
844
|
+
exists: true,
|
|
845
|
+
kind: referencesClientModule && callsIslandRender
|
|
846
|
+
? "client_mount"
|
|
847
|
+
: placeholder
|
|
848
|
+
? "placeholder"
|
|
849
|
+
: "custom",
|
|
850
|
+
referencesClientModule,
|
|
851
|
+
callsIslandRender,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function buildRuntimeConsistencyChecks(
|
|
856
|
+
pageClientMounts: PageClientMountStatus[],
|
|
857
|
+
bundleManifest: BundleManifest | null,
|
|
858
|
+
): RuntimeConsistencyCheck[] {
|
|
859
|
+
const activeMounts = pageClientMounts.filter((mount) => mount.needsClientMount);
|
|
860
|
+
const missingClientModule = activeMounts
|
|
861
|
+
.filter((mount) => !mount.hasClientModule)
|
|
862
|
+
.map((mount) => mount.routeId);
|
|
863
|
+
const missingBundle = activeMounts
|
|
864
|
+
.filter((mount) => mount.hasClientModule && bundleManifest && !mount.bundleUrl)
|
|
865
|
+
.map((mount) => mount.routeId);
|
|
866
|
+
const generatedMismatch = activeMounts
|
|
867
|
+
.filter((mount) => mount.generatedRoute.exists && mount.generatedRoute.kind !== "client_mount")
|
|
868
|
+
.map((mount) => mount.routeId);
|
|
869
|
+
|
|
870
|
+
return [
|
|
871
|
+
{
|
|
872
|
+
check: "hydrating-routes-have-client-module",
|
|
873
|
+
status: missingClientModule.length > 0 ? "fail" : "pass",
|
|
874
|
+
failingRoutes: missingClientModule,
|
|
875
|
+
},
|
|
876
|
+
{
|
|
877
|
+
check: "client-modules-have-route-bundles",
|
|
878
|
+
status: !bundleManifest ? "skip" : missingBundle.length > 0 ? "fail" : "pass",
|
|
879
|
+
failingRoutes: missingBundle,
|
|
880
|
+
reason: !bundleManifest ? "No .mandu/manifest.json found. Run mandu.build first." : undefined,
|
|
881
|
+
},
|
|
882
|
+
{
|
|
883
|
+
check: "generated-routes-match-client-modules",
|
|
884
|
+
status: generatedMismatch.length > 0 ? "fail" : "pass",
|
|
885
|
+
failingRoutes: generatedMismatch,
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
check: "terminology-separated",
|
|
889
|
+
status: "pass",
|
|
890
|
+
reason: "pageClientMounts, islands, and partials are reported as separate collections.",
|
|
891
|
+
},
|
|
892
|
+
];
|
|
893
|
+
}
|
|
894
|
+
|
|
643
895
|
function insertAfter(content: string, search: string): boolean {
|
|
644
896
|
return content.includes(search);
|
|
645
897
|
}
|