@mandujs/core 0.54.11 → 0.54.13

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.
Files changed (76) hide show
  1. package/package.json +208 -200
  2. package/scripts/postinstall-lock.ts +153 -153
  3. package/src/a11y/run-audit.ts +15 -15
  4. package/src/agent/__tests__/context.test.ts +49 -1
  5. package/src/agent/context.ts +535 -535
  6. package/src/agent/index.ts +6 -6
  7. package/src/agent/plan.ts +282 -282
  8. package/src/agent/repair.ts +171 -171
  9. package/src/agent/sync.ts +200 -200
  10. package/src/agent/types.ts +8 -0
  11. package/src/agent/verify.ts +100 -2
  12. package/src/brain/doctor/analyzer.ts +7 -7
  13. package/src/bundler/__tests__/build-runner.ts +5 -4
  14. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  15. package/src/bundler/__tests__/css.test.ts +20 -20
  16. package/src/bundler/analyzer.ts +15 -15
  17. package/src/bundler/build.test.ts +73 -7
  18. package/src/bundler/build.ts +139 -31
  19. package/src/bundler/css.ts +42 -42
  20. package/src/bundler/manifest-schema.ts +21 -21
  21. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  22. package/src/bundler/plugins/block-generated-imports.ts +13 -13
  23. package/src/bundler/types.ts +31 -31
  24. package/src/client/island.ts +79 -79
  25. package/src/config/validate.ts +1 -1
  26. package/src/contract/schema.ts +7 -0
  27. package/src/deploy/inference/context.ts +82 -82
  28. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  29. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  30. package/src/error/formatter.ts +10 -1
  31. package/src/experimental/index.ts +10 -0
  32. package/src/filling/context.ts +17 -17
  33. package/src/filling/filling.ts +22 -1
  34. package/src/filling/index.ts +15 -1
  35. package/src/generator/generate.ts +30 -30
  36. package/src/generator/index.ts +3 -3
  37. package/src/generator/templates.ts +210 -210
  38. package/src/guard/check.ts +9 -9
  39. package/src/guard/config-guard.ts +13 -13
  40. package/src/guard/fs-routes-policy.ts +51 -51
  41. package/src/guard/index.ts +11 -11
  42. package/src/index.ts +0 -10
  43. package/src/internal/index.ts +25 -0
  44. package/src/kitchen/api/file-api.ts +11 -11
  45. package/src/report/index.ts +1 -1
  46. package/src/resource/__tests__/generator.test.ts +6 -6
  47. package/src/resource/__tests__/schema.test.ts +14 -14
  48. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  49. package/src/resource/ddl/emit.ts +146 -146
  50. package/src/resource/generator-schema.ts +11 -11
  51. package/src/resource/generators/slot.ts +72 -72
  52. package/src/resource/schema.ts +21 -21
  53. package/src/router/client-entry.test.ts +69 -33
  54. package/src/router/client-entry.ts +134 -74
  55. package/src/router/fs-routes.ts +24 -22
  56. package/src/router/fs-scanner.ts +21 -17
  57. package/src/router/fs-types.ts +8 -5
  58. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  59. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  60. package/src/runtime/__tests__/page-render-response.test.ts +103 -103
  61. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  62. package/src/runtime/devtools-adapter.ts +68 -68
  63. package/src/runtime/escape.ts +34 -34
  64. package/src/runtime/image-feature.ts +15 -0
  65. package/src/runtime/observability-lifecycle.ts +290 -290
  66. package/src/runtime/page-render-response.ts +106 -106
  67. package/src/runtime/rate-limit.ts +231 -0
  68. package/src/runtime/request-middleware.ts +31 -31
  69. package/src/runtime/scheduler-lifecycle.ts +64 -0
  70. package/src/runtime/server.ts +27 -295
  71. package/src/runtime/ssr.ts +59 -59
  72. package/src/runtime/static-files.ts +289 -289
  73. package/src/runtime/streaming-ssr.ts +22 -22
  74. package/src/spec/schema.ts +4 -3
  75. package/src/watcher/__tests__/watcher.test.ts +59 -59
  76. package/src/watcher/watcher.ts +61 -61
@@ -1,535 +1,535 @@
1
- import fs from "fs/promises";
2
- import path from "path";
3
- import {
4
- CONFIG_FILES,
5
- loadManduConfig,
6
- type ManduConfig,
7
- } from "../config";
8
- import { RoutesManifest, type RouteSpec } from "../spec/schema";
9
- import { scanRoutes } from "../router";
10
- import { runExtendedDiagnose } from "../diagnose";
11
- import type {
12
- AgentArtifactSummary,
13
- AgentCommandMap,
14
- AgentContext,
15
- AgentDiagnostic,
16
- AgentDiagnosticSeverity,
17
- AgentEnvFileSummary,
18
- AgentGitSummary,
19
- AgentGuardSummary,
20
- AgentManifest,
21
- AgentRouteSummary,
22
- BuildAgentContextOptions,
23
- } from "./types";
24
-
25
- const AGENT_MANIFEST_RELATIVE_PATH = path.join(".mandu", "agent-manifest.json");
26
-
27
- const COMMANDS: AgentCommandMap = {
28
- context: "mandu agent context --json",
29
- manifest: "mandu agent manifest --write",
30
- plan: "mandu agent plan \"<task>\" --json",
31
- apply: "mandu agent apply --from .mandu/agent-plan.json",
32
- verify: "mandu agent verify --changed --json",
33
- repair: "mandu agent repair --from .mandu/agent-verify.json",
34
- sync: "mandu agent sync --target all",
35
- };
36
-
37
- function toPosix(value: string): string {
38
- return value.split(path.sep).join("/");
39
- }
40
-
41
- async function pathExists(filePath: string): Promise<boolean> {
42
- try {
43
- await fs.access(filePath);
44
- return true;
45
- } catch {
46
- return false;
47
- }
48
- }
49
-
50
- async function readJson<T>(filePath: string): Promise<T | null> {
51
- try {
52
- const raw = await fs.readFile(filePath, "utf8");
53
- return JSON.parse(raw) as T;
54
- } catch {
55
- return null;
56
- }
57
- }
58
-
59
- async function readPackageJson(rootDir: string): Promise<Record<string, unknown>> {
60
- return (await readJson<Record<string, unknown>>(path.join(rootDir, "package.json"))) ?? {};
61
- }
62
-
63
- async function detectConfigFile(rootDir: string): Promise<string | null> {
64
- for (const name of CONFIG_FILES) {
65
- if (await pathExists(path.join(rootDir, name))) {
66
- return name;
67
- }
68
- }
69
- return null;
70
- }
71
-
72
- async function loadConfig(rootDir: string): Promise<{
73
- config: ManduConfig | null;
74
- configFile: string | null;
75
- warning?: string;
76
- }> {
77
- const configFile = await detectConfigFile(rootDir);
78
- if (!configFile) return { config: null, configFile: null };
79
- try {
80
- return { config: await loadManduConfig(rootDir), configFile };
81
- } catch (err) {
82
- return {
83
- config: null,
84
- configFile,
85
- warning: `Failed to load ${configFile}: ${err instanceof Error ? err.message : String(err)}`,
86
- };
87
- }
88
- }
89
-
90
- function packageManagerFor(pkg: Record<string, unknown>): string {
91
- if (typeof pkg.packageManager === "string" && pkg.packageManager.length > 0) {
92
- return pkg.packageManager;
93
- }
94
- // Mandu is Bun-first. Prefer lockfile evidence before falling back.
95
- return "bun";
96
- }
97
-
98
- function summarizeGuard(config: ManduConfig | null): AgentGuardSummary {
99
- const guard = config?.guard;
100
- let customRules = 0;
101
- let ruleOverrides = 0;
102
- if (Array.isArray(guard?.rules)) {
103
- customRules = guard.rules.length;
104
- } else if (guard?.rules && typeof guard.rules === "object") {
105
- ruleOverrides = Object.keys(guard.rules).length;
106
- }
107
- return {
108
- preset: typeof guard?.preset === "string" ? guard.preset : null,
109
- customRules,
110
- ruleOverrides,
111
- };
112
- }
113
-
114
- function summarizeRoute(route: RouteSpec | {
115
- id: string;
116
- pattern: string;
117
- kind: string;
118
- module: string;
119
- methods?: string[];
120
- hydration?: { strategy?: string; priority?: string };
121
- clientModule?: string;
122
- contractModule?: string;
123
- layoutChain?: string[];
124
- metadataKind?: string;
125
- }): AgentRouteSummary {
126
- const metadataKind =
127
- "metadataKind" in route && typeof route.metadataKind === "string"
128
- ? route.metadataKind
129
- : undefined;
130
- return {
131
- id: route.id,
132
- pattern: route.pattern,
133
- kind: route.kind,
134
- module: route.module,
135
- ...(Array.isArray(route.methods) ? { methods: route.methods } : {}),
136
- ...(route.hydration
137
- ? {
138
- hydration: {
139
- strategy: route.hydration.strategy,
140
- priority: route.hydration.priority,
141
- },
142
- }
143
- : {}),
144
- hasClientModule: typeof route.clientModule === "string" && route.clientModule.length > 0,
145
- hasContractModule:
146
- typeof route.contractModule === "string" && route.contractModule.length > 0,
147
- layoutDepth: Array.isArray(route.layoutChain) ? route.layoutChain.length : 0,
148
- ...(metadataKind ? { metadataKind } : {}),
149
- };
150
- }
151
-
152
- async function collectRoutes(rootDir: string, config: ManduConfig | null, warnings: string[]): Promise<{
153
- source: AgentContext["routeSource"];
154
- routes: AgentRouteSummary[];
155
- }> {
156
- const manifestPath = path.join(rootDir, ".mandu", "routes.manifest.json");
157
- if (await pathExists(manifestPath)) {
158
- const raw = await readJson<unknown>(manifestPath);
159
- const parsed = RoutesManifest.safeParse(raw);
160
- if (parsed.success) {
161
- return {
162
- source: "manifest",
163
- routes: parsed.data.routes.map(summarizeRoute),
164
- };
165
- }
166
- warnings.push(
167
- `Route manifest exists but is invalid; falling back to scanner (${parsed.error.issues[0]?.message ?? "schema error"}).`,
168
- );
169
- }
170
-
171
- const routesDir = config?.fsRoutes?.routesDir ?? "app";
172
- const appDir = path.join(rootDir, routesDir);
173
- if (!(await pathExists(appDir))) {
174
- return { source: "none", routes: [] };
175
- }
176
-
177
- try {
178
- const scan = await scanRoutes(rootDir, config?.fsRoutes);
179
- return {
180
- source: "scanner",
181
- routes: scan.routes.map((route) => summarizeRoute(route)),
182
- };
183
- } catch (err) {
184
- warnings.push(`Failed to scan routes: ${err instanceof Error ? err.message : String(err)}`);
185
- return { source: "none", routes: [] };
186
- }
187
- }
188
-
189
- type ArtifactKind = AgentArtifactSummary["kind"];
190
-
191
- const ARTIFACT_RULES: Array<{
192
- kind: ArtifactKind;
193
- roots: string[];
194
- suffixes: string[];
195
- }> = [
196
- { kind: "partial", roots: ["app", "src"], suffixes: [".partial.tsx", ".partial.ts", ".partial.jsx", ".partial.js"] },
197
- { kind: "island", roots: ["app", "src"], suffixes: [".island.tsx", ".island.ts", ".island.jsx", ".island.js"] },
198
- { kind: "slot", roots: [path.join("spec", "slots"), "app"], suffixes: [".slot.ts", ".slot.tsx", ".slot.js", ".slot.jsx"] },
199
- { kind: "contract", roots: [path.join("spec", "contracts"), "app"], suffixes: [".contract.ts", ".contract.tsx", ".contract.js", ".contract.jsx"] },
200
- ];
201
-
202
- async function walk(root: string, out: string[], maxFiles = 3000): Promise<void> {
203
- if (out.length >= maxFiles || !(await pathExists(root))) return;
204
- const entries = await fs.readdir(root, { withFileTypes: true });
205
- for (const entry of entries) {
206
- if (out.length >= maxFiles) return;
207
- if (entry.name === "node_modules" || entry.name === ".mandu" || entry.name === ".git") {
208
- continue;
209
- }
210
- const abs = path.join(root, entry.name);
211
- if (entry.isDirectory()) {
212
- await walk(abs, out, maxFiles);
213
- } else if (entry.isFile()) {
214
- out.push(abs);
215
- }
216
- }
217
- }
218
-
219
- async function collectArtifacts(rootDir: string): Promise<Record<ArtifactKind, AgentArtifactSummary[]>> {
220
- const result: Record<ArtifactKind, AgentArtifactSummary[]> = {
221
- partial: [],
222
- island: [],
223
- slot: [],
224
- contract: [],
225
- };
226
-
227
- for (const rule of ARTIFACT_RULES) {
228
- const files: string[] = [];
229
- for (const root of rule.roots) {
230
- await walk(path.join(rootDir, root), files);
231
- }
232
- const seen = new Set<string>();
233
- for (const abs of files) {
234
- if (!rule.suffixes.some((suffix) => abs.endsWith(suffix))) continue;
235
- const rel = toPosix(path.relative(rootDir, abs));
236
- if (seen.has(rel)) continue;
237
- seen.add(rel);
238
- result[rule.kind].push({ path: rel, kind: rule.kind });
239
- }
240
- result[rule.kind].sort((a, b) => a.path.localeCompare(b.path));
241
- }
242
-
243
- return result;
244
- }
245
-
246
- async function collectEnv(rootDir: string): Promise<AgentEnvFileSummary[]> {
247
- const candidates: Array<[string, AgentEnvFileSummary["kind"]]> = [
248
- [".env", "local"],
249
- [".env.local", "local"],
250
- [".env.development", "local"],
251
- [".env.test", "test"],
252
- [".env.production", "production"],
253
- [".env.example", "template"],
254
- [".env.sample", "template"],
255
- ];
256
-
257
- const out: AgentEnvFileSummary[] = [];
258
- for (const [name, kind] of candidates) {
259
- if (await pathExists(path.join(rootDir, name))) {
260
- out.push({ path: name, kind, redacted: true });
261
- }
262
- }
263
- return out;
264
- }
265
-
266
- async function collectDeploy(rootDir: string): Promise<AgentContext["deploy"]> {
267
- const intentPath = path.join(".mandu", "deploy.intent.json");
268
- const targets: string[] = [];
269
- for (const [file, target] of [
270
- ["vercel.json", "vercel"],
271
- ["netlify.toml", "netlify"],
272
- ["fly.toml", "fly"],
273
- ["render.yaml", "render"],
274
- ["wrangler.toml", "workers"],
275
- ["Dockerfile", "docker"],
276
- ["docker-compose.yml", "docker-compose"],
277
- ] as const) {
278
- if (await pathExists(path.join(rootDir, file))) {
279
- targets.push(target);
280
- }
281
- }
282
- return {
283
- intentFile: (await pathExists(path.join(rootDir, intentPath))) ? intentPath : null,
284
- targets,
285
- };
286
- }
287
-
288
- function mapSeverity(severity: "error" | "warning" | "info" | undefined): AgentDiagnosticSeverity {
289
- if (severity === "error") return "error";
290
- if (severity === "warning") return "warning";
291
- return "info";
292
- }
293
-
294
- function diagnosticsFromDiagnose(report: Awaited<ReturnType<typeof runExtendedDiagnose>>): AgentDiagnostic[] {
295
- return report.checks
296
- .filter((check) => !check.ok)
297
- .map((check) => ({
298
- code: `MANDU_DIAGNOSE_${check.rule.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`,
299
- severity: mapSeverity(check.severity),
300
- title: check.rule,
301
- cause: check.message,
302
- ...(check.suggestion
303
- ? {
304
- suggestedFix: {
305
- type: "manual" as const,
306
- description: check.suggestion,
307
- },
308
- }
309
- : {}),
310
- docs: "docs/plans/20_agent_surface_consolidation_plan.md",
311
- repairable: Boolean(check.suggestion),
312
- source: "diagnose",
313
- }));
314
- }
315
-
316
- async function runGit(
317
- rootDir: string,
318
- args: string[],
319
- timeoutMs = 3000,
320
- ): Promise<string | null> {
321
- if (typeof Bun === "undefined") return null;
322
- try {
323
- const proc = Bun.spawn(["git", ...args], {
324
- cwd: rootDir,
325
- stdout: "pipe",
326
- stderr: "pipe",
327
- });
328
- const timeout = new Promise<null>((resolve) => {
329
- setTimeout(() => {
330
- try {
331
- proc.kill();
332
- } catch {
333
- // ignore
334
- }
335
- resolve(null);
336
- }, timeoutMs);
337
- });
338
- const output = new Response(proc.stdout).text();
339
- const result = await Promise.race([output, timeout]);
340
- const exit = await proc.exited.catch(() => 1);
341
- if (result === null || exit !== 0) return null;
342
- return result.trim();
343
- } catch {
344
- return null;
345
- }
346
- }
347
-
348
- async function collectGit(rootDir: string, includeGit: boolean): Promise<AgentGitSummary> {
349
- if (!includeGit) {
350
- return { branch: null, changedFiles: [], statusAvailable: false };
351
- }
352
-
353
- const branch = await runGit(rootDir, ["branch", "--show-current"]);
354
- const status = await runGit(rootDir, ["status", "--short"]);
355
- if (branch === null && status === null) {
356
- return { branch: null, changedFiles: [], statusAvailable: false };
357
- }
358
- const changedFiles =
359
- status
360
- ?.split(/\r?\n/)
361
- .map((line) => line.trim())
362
- .filter(Boolean)
363
- .map((line) => line.replace(/^[ MADRCU?!]{1,2}\s+/, ""))
364
- .slice(0, 200) ?? [];
365
-
366
- return {
367
- branch: branch || null,
368
- changedFiles,
369
- statusAvailable: true,
370
- };
371
- }
372
-
373
- function buildWorkflow(): AgentContext["agentWorkflow"] {
374
- return {
375
- canonical: ["context", "plan", "apply", "verify", "repair"],
376
- recommended: [
377
- {
378
- step: "context",
379
- command: COMMANDS.context,
380
- purpose: "Understand the project before selecting tools or editing files.",
381
- },
382
- {
383
- step: "plan",
384
- command: COMMANDS.plan,
385
- purpose: "Map the user's intent to domains, files, risks, and verification gates.",
386
- },
387
- {
388
- step: "apply",
389
- command: COMMANDS.apply,
390
- purpose: "Run planned intent-level changes before falling back to direct file edits.",
391
- },
392
- {
393
- step: "verify",
394
- command: COMMANDS.verify,
395
- purpose: "Collapse guard, diagnose, contract, and test signals into one agent-facing report.",
396
- },
397
- {
398
- step: "repair",
399
- command: COMMANDS.repair,
400
- purpose: "Convert verification failures into safe next actions or patch candidates.",
401
- },
402
- ],
403
- };
404
- }
405
-
406
- export function agentManifestPath(rootDir: string): string {
407
- return path.join(rootDir, AGENT_MANIFEST_RELATIVE_PATH);
408
- }
409
-
410
- export function toAgentManifest(context: AgentContext): AgentManifest {
411
- return {
412
- schemaVersion: context.schemaVersion,
413
- framework: context.framework,
414
- generatedAt: context.generatedAt,
415
- project: context.project,
416
- routeSource: context.routeSource,
417
- routes: context.routes,
418
- apis: context.apis,
419
- layouts: [],
420
- partials: context.partials,
421
- islands: context.islands,
422
- slots: context.slots,
423
- contracts: context.contracts,
424
- guards: context.guards,
425
- env: context.env,
426
- deploy: context.deploy,
427
- commands: context.commands,
428
- agentWorkflow: context.agentWorkflow,
429
- warnings: context.warnings,
430
- };
431
- }
432
-
433
- export async function buildAgentContext(
434
- rootDir: string = process.cwd(),
435
- options: BuildAgentContextOptions = {},
436
- ): Promise<AgentContext> {
437
- const includeDiagnose = options.includeDiagnose !== false;
438
- const includeGit = options.includeGit !== false;
439
- const root = path.resolve(rootDir);
440
- const warnings: string[] = [];
441
- const diagnostics: AgentDiagnostic[] = [];
442
-
443
- const pkg = await readPackageJson(root);
444
- const { config, configFile, warning } = await loadConfig(root);
445
- if (warning) warnings.push(warning);
446
-
447
- const [routeResult, artifacts, env, deploy, git] = await Promise.all([
448
- collectRoutes(root, config, warnings),
449
- collectArtifacts(root),
450
- collectEnv(root),
451
- collectDeploy(root),
452
- collectGit(root, includeGit),
453
- ]);
454
-
455
- let diagnose: AgentContext["diagnose"] | undefined;
456
- if (includeDiagnose) {
457
- try {
458
- const report = await runExtendedDiagnose(root);
459
- diagnose = {
460
- healthy: report.healthy,
461
- errorCount: report.errorCount,
462
- warningCount: report.warningCount,
463
- summary: report.summary,
464
- };
465
- diagnostics.push(...diagnosticsFromDiagnose(report));
466
- } catch (err) {
467
- diagnostics.push({
468
- code: "MANDU_DIAGNOSE_UNAVAILABLE",
469
- severity: "warning",
470
- title: "Diagnose unavailable",
471
- cause: err instanceof Error ? err.message : String(err),
472
- suggestedFix: {
473
- type: "manual",
474
- description: "Run `mandu diagnose --json` directly for more detail.",
475
- },
476
- docs: "docs/plans/20_agent_surface_consolidation_plan.md",
477
- repairable: false,
478
- source: "agent.context",
479
- });
480
- }
481
- }
482
-
483
- const routes = routeResult.routes;
484
- return {
485
- schemaVersion: 1,
486
- framework: "mandu",
487
- generatedAt: new Date().toISOString(),
488
- project: {
489
- name: typeof pkg.name === "string" ? pkg.name : null,
490
- version: typeof pkg.version === "string" ? pkg.version : null,
491
- root,
492
- packageManager: packageManagerFor(pkg),
493
- configFile,
494
- },
495
- routeSource: routeResult.source,
496
- routes,
497
- pages: routes.filter((route) => route.kind === "page"),
498
- apis: routes.filter((route) => route.kind === "api"),
499
- metadataRoutes: routes.filter((route) => route.kind === "metadata"),
500
- partials: artifacts.partial,
501
- islands: artifacts.island,
502
- slots: artifacts.slot,
503
- contracts: artifacts.contract,
504
- guards: summarizeGuard(config),
505
- env,
506
- deploy,
507
- commands: COMMANDS,
508
- agentWorkflow: buildWorkflow(),
509
- ...(diagnose ? { diagnose } : {}),
510
- diagnostics,
511
- git,
512
- warnings,
513
- };
514
- }
515
-
516
- export async function writeAgentManifest(
517
- rootDir: string = process.cwd(),
518
- contextOrManifest?: AgentContext | AgentManifest,
519
- ): Promise<{ path: string; manifest: AgentManifest }> {
520
- const manifest =
521
- contextOrManifest && "project" in contextOrManifest && "agentWorkflow" in contextOrManifest
522
- ? "diagnostics" in contextOrManifest
523
- ? toAgentManifest(contextOrManifest as AgentContext)
524
- : (contextOrManifest as AgentManifest)
525
- : toAgentManifest(await buildAgentContext(rootDir));
526
-
527
- const outPath = agentManifestPath(rootDir);
528
- await fs.mkdir(path.dirname(outPath), { recursive: true });
529
- await fs.writeFile(outPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
530
- return { path: outPath, manifest };
531
- }
532
-
533
- export async function readAgentManifest(rootDir: string = process.cwd()): Promise<AgentManifest | null> {
534
- return readJson<AgentManifest>(agentManifestPath(rootDir));
535
- }
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import {
4
+ CONFIG_FILES,
5
+ loadManduConfig,
6
+ type ManduConfig,
7
+ } from "../config";
8
+ import { RoutesManifest, type RouteSpec } from "../spec/schema";
9
+ import { scanRoutes } from "../router";
10
+ import { runExtendedDiagnose } from "../diagnose";
11
+ import type {
12
+ AgentArtifactSummary,
13
+ AgentCommandMap,
14
+ AgentContext,
15
+ AgentDiagnostic,
16
+ AgentDiagnosticSeverity,
17
+ AgentEnvFileSummary,
18
+ AgentGitSummary,
19
+ AgentGuardSummary,
20
+ AgentManifest,
21
+ AgentRouteSummary,
22
+ BuildAgentContextOptions,
23
+ } from "./types";
24
+
25
+ const AGENT_MANIFEST_RELATIVE_PATH = path.join(".mandu", "agent-manifest.json");
26
+
27
+ const COMMANDS: AgentCommandMap = {
28
+ context: "mandu agent context --json",
29
+ manifest: "mandu agent manifest --write",
30
+ plan: "mandu agent plan \"<task>\" --json",
31
+ apply: "mandu agent apply --from .mandu/agent-plan.json",
32
+ verify: "mandu agent verify --changed --json",
33
+ repair: "mandu agent repair --from .mandu/agent-verify.json",
34
+ sync: "mandu agent sync --target all",
35
+ };
36
+
37
+ function toPosix(value: string): string {
38
+ return value.split(path.sep).join("/");
39
+ }
40
+
41
+ async function pathExists(filePath: string): Promise<boolean> {
42
+ try {
43
+ await fs.access(filePath);
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ async function readJson<T>(filePath: string): Promise<T | null> {
51
+ try {
52
+ const raw = await fs.readFile(filePath, "utf8");
53
+ return JSON.parse(raw) as T;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ async function readPackageJson(rootDir: string): Promise<Record<string, unknown>> {
60
+ return (await readJson<Record<string, unknown>>(path.join(rootDir, "package.json"))) ?? {};
61
+ }
62
+
63
+ async function detectConfigFile(rootDir: string): Promise<string | null> {
64
+ for (const name of CONFIG_FILES) {
65
+ if (await pathExists(path.join(rootDir, name))) {
66
+ return name;
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ async function loadConfig(rootDir: string): Promise<{
73
+ config: ManduConfig | null;
74
+ configFile: string | null;
75
+ warning?: string;
76
+ }> {
77
+ const configFile = await detectConfigFile(rootDir);
78
+ if (!configFile) return { config: null, configFile: null };
79
+ try {
80
+ return { config: await loadManduConfig(rootDir), configFile };
81
+ } catch (err) {
82
+ return {
83
+ config: null,
84
+ configFile,
85
+ warning: `Failed to load ${configFile}: ${err instanceof Error ? err.message : String(err)}`,
86
+ };
87
+ }
88
+ }
89
+
90
+ function packageManagerFor(pkg: Record<string, unknown>): string {
91
+ if (typeof pkg.packageManager === "string" && pkg.packageManager.length > 0) {
92
+ return pkg.packageManager;
93
+ }
94
+ // Mandu is Bun-first. Prefer lockfile evidence before falling back.
95
+ return "bun";
96
+ }
97
+
98
+ function summarizeGuard(config: ManduConfig | null): AgentGuardSummary {
99
+ const guard = config?.guard;
100
+ let customRules = 0;
101
+ let ruleOverrides = 0;
102
+ if (Array.isArray(guard?.rules)) {
103
+ customRules = guard.rules.length;
104
+ } else if (guard?.rules && typeof guard.rules === "object") {
105
+ ruleOverrides = Object.keys(guard.rules).length;
106
+ }
107
+ return {
108
+ preset: typeof guard?.preset === "string" ? guard.preset : null,
109
+ customRules,
110
+ ruleOverrides,
111
+ };
112
+ }
113
+
114
+ function summarizeRoute(route: RouteSpec | {
115
+ id: string;
116
+ pattern: string;
117
+ kind: string;
118
+ module: string;
119
+ methods?: string[];
120
+ hydration?: { strategy?: string; priority?: string };
121
+ clientModule?: string;
122
+ contractModule?: string;
123
+ layoutChain?: string[];
124
+ metadataKind?: string;
125
+ }): AgentRouteSummary {
126
+ const metadataKind =
127
+ "metadataKind" in route && typeof route.metadataKind === "string"
128
+ ? route.metadataKind
129
+ : undefined;
130
+ return {
131
+ id: route.id,
132
+ pattern: route.pattern,
133
+ kind: route.kind,
134
+ module: route.module,
135
+ ...(Array.isArray(route.methods) ? { methods: route.methods } : {}),
136
+ ...(route.hydration
137
+ ? {
138
+ hydration: {
139
+ strategy: route.hydration.strategy,
140
+ priority: route.hydration.priority,
141
+ },
142
+ }
143
+ : {}),
144
+ hasClientModule: typeof route.clientModule === "string" && route.clientModule.length > 0,
145
+ hasContractModule:
146
+ typeof route.contractModule === "string" && route.contractModule.length > 0,
147
+ layoutDepth: Array.isArray(route.layoutChain) ? route.layoutChain.length : 0,
148
+ ...(metadataKind ? { metadataKind } : {}),
149
+ };
150
+ }
151
+
152
+ async function collectRoutes(rootDir: string, config: ManduConfig | null, warnings: string[]): Promise<{
153
+ source: AgentContext["routeSource"];
154
+ routes: AgentRouteSummary[];
155
+ }> {
156
+ const manifestPath = path.join(rootDir, ".mandu", "routes.manifest.json");
157
+ if (await pathExists(manifestPath)) {
158
+ const raw = await readJson<unknown>(manifestPath);
159
+ const parsed = RoutesManifest.safeParse(raw);
160
+ if (parsed.success) {
161
+ return {
162
+ source: "manifest",
163
+ routes: parsed.data.routes.map(summarizeRoute),
164
+ };
165
+ }
166
+ warnings.push(
167
+ `Route manifest exists but is invalid; falling back to scanner (${parsed.error.issues[0]?.message ?? "schema error"}).`,
168
+ );
169
+ }
170
+
171
+ const routesDir = config?.fsRoutes?.routesDir ?? "app";
172
+ const appDir = path.join(rootDir, routesDir);
173
+ if (!(await pathExists(appDir))) {
174
+ return { source: "none", routes: [] };
175
+ }
176
+
177
+ try {
178
+ const scan = await scanRoutes(rootDir, config?.fsRoutes);
179
+ return {
180
+ source: "scanner",
181
+ routes: scan.routes.map((route) => summarizeRoute(route)),
182
+ };
183
+ } catch (err) {
184
+ warnings.push(`Failed to scan routes: ${err instanceof Error ? err.message : String(err)}`);
185
+ return { source: "none", routes: [] };
186
+ }
187
+ }
188
+
189
+ type ArtifactKind = AgentArtifactSummary["kind"];
190
+
191
+ const ARTIFACT_RULES: Array<{
192
+ kind: ArtifactKind;
193
+ roots: string[];
194
+ suffixes: string[];
195
+ }> = [
196
+ { kind: "partial", roots: ["app", "src"], suffixes: [".partial.tsx", ".partial.ts", ".partial.jsx", ".partial.js"] },
197
+ { kind: "island", roots: ["app", "src"], suffixes: [".island.tsx", ".island.ts", ".island.jsx", ".island.js"] },
198
+ { kind: "slot", roots: [path.join("spec", "slots"), "app"], suffixes: [".slot.ts", ".slot.tsx", ".slot.js", ".slot.jsx"] },
199
+ { kind: "contract", roots: [path.join("spec", "contracts"), "app"], suffixes: [".contract.ts", ".contract.tsx", ".contract.js", ".contract.jsx"] },
200
+ ];
201
+
202
+ async function walk(root: string, out: string[], maxFiles = 3000): Promise<void> {
203
+ if (out.length >= maxFiles || !(await pathExists(root))) return;
204
+ const entries = await fs.readdir(root, { withFileTypes: true });
205
+ for (const entry of entries) {
206
+ if (out.length >= maxFiles) return;
207
+ if (entry.name === "node_modules" || entry.name === ".mandu" || entry.name === ".git") {
208
+ continue;
209
+ }
210
+ const abs = path.join(root, entry.name);
211
+ if (entry.isDirectory()) {
212
+ await walk(abs, out, maxFiles);
213
+ } else if (entry.isFile()) {
214
+ out.push(abs);
215
+ }
216
+ }
217
+ }
218
+
219
+ async function collectArtifacts(rootDir: string): Promise<Record<ArtifactKind, AgentArtifactSummary[]>> {
220
+ const result: Record<ArtifactKind, AgentArtifactSummary[]> = {
221
+ partial: [],
222
+ island: [],
223
+ slot: [],
224
+ contract: [],
225
+ };
226
+
227
+ for (const rule of ARTIFACT_RULES) {
228
+ const files: string[] = [];
229
+ for (const root of rule.roots) {
230
+ await walk(path.join(rootDir, root), files);
231
+ }
232
+ const seen = new Set<string>();
233
+ for (const abs of files) {
234
+ if (!rule.suffixes.some((suffix) => abs.endsWith(suffix))) continue;
235
+ const rel = toPosix(path.relative(rootDir, abs));
236
+ if (seen.has(rel)) continue;
237
+ seen.add(rel);
238
+ result[rule.kind].push({ path: rel, kind: rule.kind });
239
+ }
240
+ result[rule.kind].sort((a, b) => a.path.localeCompare(b.path));
241
+ }
242
+
243
+ return result;
244
+ }
245
+
246
+ async function collectEnv(rootDir: string): Promise<AgentEnvFileSummary[]> {
247
+ const candidates: Array<[string, AgentEnvFileSummary["kind"]]> = [
248
+ [".env", "local"],
249
+ [".env.local", "local"],
250
+ [".env.development", "local"],
251
+ [".env.test", "test"],
252
+ [".env.production", "production"],
253
+ [".env.example", "template"],
254
+ [".env.sample", "template"],
255
+ ];
256
+
257
+ const out: AgentEnvFileSummary[] = [];
258
+ for (const [name, kind] of candidates) {
259
+ if (await pathExists(path.join(rootDir, name))) {
260
+ out.push({ path: name, kind, redacted: true });
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+
266
+ async function collectDeploy(rootDir: string): Promise<AgentContext["deploy"]> {
267
+ const intentPath = path.join(".mandu", "deploy.intent.json");
268
+ const targets: string[] = [];
269
+ for (const [file, target] of [
270
+ ["vercel.json", "vercel"],
271
+ ["netlify.toml", "netlify"],
272
+ ["fly.toml", "fly"],
273
+ ["render.yaml", "render"],
274
+ ["wrangler.toml", "workers"],
275
+ ["Dockerfile", "docker"],
276
+ ["docker-compose.yml", "docker-compose"],
277
+ ] as const) {
278
+ if (await pathExists(path.join(rootDir, file))) {
279
+ targets.push(target);
280
+ }
281
+ }
282
+ return {
283
+ intentFile: (await pathExists(path.join(rootDir, intentPath))) ? intentPath : null,
284
+ targets,
285
+ };
286
+ }
287
+
288
+ function mapSeverity(severity: "error" | "warning" | "info" | undefined): AgentDiagnosticSeverity {
289
+ if (severity === "error") return "error";
290
+ if (severity === "warning") return "warning";
291
+ return "info";
292
+ }
293
+
294
+ function diagnosticsFromDiagnose(report: Awaited<ReturnType<typeof runExtendedDiagnose>>): AgentDiagnostic[] {
295
+ return report.checks
296
+ .filter((check) => !check.ok)
297
+ .map((check) => ({
298
+ code: `MANDU_DIAGNOSE_${check.rule.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`,
299
+ severity: mapSeverity(check.severity),
300
+ title: check.rule,
301
+ cause: check.message,
302
+ ...(check.suggestion
303
+ ? {
304
+ suggestedFix: {
305
+ type: "manual" as const,
306
+ description: check.suggestion,
307
+ },
308
+ }
309
+ : {}),
310
+ docs: "docs/plans/20_agent_surface_consolidation_plan.md",
311
+ repairable: Boolean(check.suggestion),
312
+ source: "diagnose",
313
+ }));
314
+ }
315
+
316
+ async function runGit(
317
+ rootDir: string,
318
+ args: string[],
319
+ timeoutMs = 3000,
320
+ ): Promise<string | null> {
321
+ if (typeof Bun === "undefined") return null;
322
+ try {
323
+ const proc = Bun.spawn(["git", ...args], {
324
+ cwd: rootDir,
325
+ stdout: "pipe",
326
+ stderr: "pipe",
327
+ });
328
+ const timeout = new Promise<null>((resolve) => {
329
+ setTimeout(() => {
330
+ try {
331
+ proc.kill();
332
+ } catch {
333
+ // ignore
334
+ }
335
+ resolve(null);
336
+ }, timeoutMs);
337
+ });
338
+ const output = new Response(proc.stdout).text();
339
+ const result = await Promise.race([output, timeout]);
340
+ const exit = await proc.exited.catch(() => 1);
341
+ if (result === null || exit !== 0) return null;
342
+ return result.trim();
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ async function collectGit(rootDir: string, includeGit: boolean): Promise<AgentGitSummary> {
349
+ if (!includeGit) {
350
+ return { branch: null, changedFiles: [], statusAvailable: false };
351
+ }
352
+
353
+ const branch = await runGit(rootDir, ["branch", "--show-current"]);
354
+ const status = await runGit(rootDir, ["status", "--short"]);
355
+ if (branch === null && status === null) {
356
+ return { branch: null, changedFiles: [], statusAvailable: false };
357
+ }
358
+ const changedFiles =
359
+ status
360
+ ?.split(/\r?\n/)
361
+ .map((line) => line.trim())
362
+ .filter(Boolean)
363
+ .map((line) => line.replace(/^[ MADRCU?!]{1,2}\s+/, ""))
364
+ .slice(0, 200) ?? [];
365
+
366
+ return {
367
+ branch: branch || null,
368
+ changedFiles,
369
+ statusAvailable: true,
370
+ };
371
+ }
372
+
373
+ function buildWorkflow(): AgentContext["agentWorkflow"] {
374
+ return {
375
+ canonical: ["context", "plan", "apply", "verify", "repair"],
376
+ recommended: [
377
+ {
378
+ step: "context",
379
+ command: COMMANDS.context,
380
+ purpose: "Understand the project before selecting tools or editing files.",
381
+ },
382
+ {
383
+ step: "plan",
384
+ command: COMMANDS.plan,
385
+ purpose: "Map the user's intent to domains, files, risks, and verification gates.",
386
+ },
387
+ {
388
+ step: "apply",
389
+ command: COMMANDS.apply,
390
+ purpose: "Run planned intent-level changes before falling back to direct file edits.",
391
+ },
392
+ {
393
+ step: "verify",
394
+ command: COMMANDS.verify,
395
+ purpose: "Collapse guard, diagnose, contract, and test signals into one agent-facing report.",
396
+ },
397
+ {
398
+ step: "repair",
399
+ command: COMMANDS.repair,
400
+ purpose: "Convert verification failures into safe next actions or patch candidates.",
401
+ },
402
+ ],
403
+ };
404
+ }
405
+
406
+ export function agentManifestPath(rootDir: string): string {
407
+ return path.join(rootDir, AGENT_MANIFEST_RELATIVE_PATH);
408
+ }
409
+
410
+ export function toAgentManifest(context: AgentContext): AgentManifest {
411
+ return {
412
+ schemaVersion: context.schemaVersion,
413
+ framework: context.framework,
414
+ generatedAt: context.generatedAt,
415
+ project: context.project,
416
+ routeSource: context.routeSource,
417
+ routes: context.routes,
418
+ apis: context.apis,
419
+ layouts: [],
420
+ partials: context.partials,
421
+ islands: context.islands,
422
+ slots: context.slots,
423
+ contracts: context.contracts,
424
+ guards: context.guards,
425
+ env: context.env,
426
+ deploy: context.deploy,
427
+ commands: context.commands,
428
+ agentWorkflow: context.agentWorkflow,
429
+ warnings: context.warnings,
430
+ };
431
+ }
432
+
433
+ export async function buildAgentContext(
434
+ rootDir: string = process.cwd(),
435
+ options: BuildAgentContextOptions = {},
436
+ ): Promise<AgentContext> {
437
+ const includeDiagnose = options.includeDiagnose !== false;
438
+ const includeGit = options.includeGit !== false;
439
+ const root = path.resolve(rootDir);
440
+ const warnings: string[] = [];
441
+ const diagnostics: AgentDiagnostic[] = [];
442
+
443
+ const pkg = await readPackageJson(root);
444
+ const { config, configFile, warning } = await loadConfig(root);
445
+ if (warning) warnings.push(warning);
446
+
447
+ const [routeResult, artifacts, env, deploy, git] = await Promise.all([
448
+ collectRoutes(root, config, warnings),
449
+ collectArtifacts(root),
450
+ collectEnv(root),
451
+ collectDeploy(root),
452
+ collectGit(root, includeGit),
453
+ ]);
454
+
455
+ let diagnose: AgentContext["diagnose"] | undefined;
456
+ if (includeDiagnose) {
457
+ try {
458
+ const report = await runExtendedDiagnose(root);
459
+ diagnose = {
460
+ healthy: report.healthy,
461
+ errorCount: report.errorCount,
462
+ warningCount: report.warningCount,
463
+ summary: report.summary,
464
+ };
465
+ diagnostics.push(...diagnosticsFromDiagnose(report));
466
+ } catch (err) {
467
+ diagnostics.push({
468
+ code: "MANDU_DIAGNOSE_UNAVAILABLE",
469
+ severity: "warning",
470
+ title: "Diagnose unavailable",
471
+ cause: err instanceof Error ? err.message : String(err),
472
+ suggestedFix: {
473
+ type: "manual",
474
+ description: "Run `mandu diagnose --json` directly for more detail.",
475
+ },
476
+ docs: "docs/plans/20_agent_surface_consolidation_plan.md",
477
+ repairable: false,
478
+ source: "agent.context",
479
+ });
480
+ }
481
+ }
482
+
483
+ const routes = routeResult.routes;
484
+ return {
485
+ schemaVersion: 1,
486
+ framework: "mandu",
487
+ generatedAt: new Date().toISOString(),
488
+ project: {
489
+ name: typeof pkg.name === "string" ? pkg.name : null,
490
+ version: typeof pkg.version === "string" ? pkg.version : null,
491
+ root,
492
+ packageManager: packageManagerFor(pkg),
493
+ configFile,
494
+ },
495
+ routeSource: routeResult.source,
496
+ routes,
497
+ pages: routes.filter((route) => route.kind === "page"),
498
+ apis: routes.filter((route) => route.kind === "api"),
499
+ metadataRoutes: routes.filter((route) => route.kind === "metadata"),
500
+ partials: artifacts.partial,
501
+ islands: artifacts.island,
502
+ slots: artifacts.slot,
503
+ contracts: artifacts.contract,
504
+ guards: summarizeGuard(config),
505
+ env,
506
+ deploy,
507
+ commands: COMMANDS,
508
+ agentWorkflow: buildWorkflow(),
509
+ ...(diagnose ? { diagnose } : {}),
510
+ diagnostics,
511
+ git,
512
+ warnings,
513
+ };
514
+ }
515
+
516
+ export async function writeAgentManifest(
517
+ rootDir: string = process.cwd(),
518
+ contextOrManifest?: AgentContext | AgentManifest,
519
+ ): Promise<{ path: string; manifest: AgentManifest }> {
520
+ const manifest =
521
+ contextOrManifest && "project" in contextOrManifest && "agentWorkflow" in contextOrManifest
522
+ ? "diagnostics" in contextOrManifest
523
+ ? toAgentManifest(contextOrManifest as AgentContext)
524
+ : (contextOrManifest as AgentManifest)
525
+ : toAgentManifest(await buildAgentContext(rootDir));
526
+
527
+ const outPath = agentManifestPath(rootDir);
528
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
529
+ await fs.writeFile(outPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
530
+ return { path: outPath, manifest };
531
+ }
532
+
533
+ export async function readAgentManifest(rootDir: string = process.cwd()): Promise<AgentManifest | null> {
534
+ return readJson<AgentManifest>(agentManifestPath(rootDir));
535
+ }