@mandujs/core 0.18.22 → 0.19.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.
Files changed (91) hide show
  1. package/README.ko.md +0 -14
  2. package/package.json +4 -1
  3. package/src/brain/architecture/analyzer.ts +4 -4
  4. package/src/brain/doctor/analyzer.ts +18 -14
  5. package/src/bundler/build.test.ts +127 -0
  6. package/src/bundler/build.ts +291 -113
  7. package/src/bundler/css.ts +20 -5
  8. package/src/bundler/dev.ts +55 -2
  9. package/src/bundler/prerender.ts +195 -0
  10. package/src/change/snapshot.ts +4 -23
  11. package/src/change/types.ts +2 -3
  12. package/src/client/Form.tsx +105 -0
  13. package/src/client/__tests__/use-sse.test.ts +153 -0
  14. package/src/client/hooks.ts +105 -6
  15. package/src/client/index.ts +35 -6
  16. package/src/client/router.ts +670 -433
  17. package/src/client/rpc.ts +140 -0
  18. package/src/client/runtime.ts +24 -21
  19. package/src/client/use-fetch.ts +239 -0
  20. package/src/client/use-head.ts +197 -0
  21. package/src/client/use-sse.ts +378 -0
  22. package/src/components/Image.tsx +162 -0
  23. package/src/config/mandu.ts +5 -0
  24. package/src/config/validate.ts +34 -0
  25. package/src/content/index.ts +5 -1
  26. package/src/devtools/client/catchers/error-catcher.ts +17 -0
  27. package/src/devtools/client/catchers/network-proxy.ts +390 -367
  28. package/src/devtools/client/components/kitchen-root.tsx +479 -467
  29. package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
  30. package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
  31. package/src/devtools/client/components/panel/index.ts +45 -32
  32. package/src/devtools/client/components/panel/panel-container.tsx +332 -312
  33. package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
  34. package/src/devtools/client/state-manager.ts +535 -478
  35. package/src/devtools/design-tokens.ts +265 -264
  36. package/src/devtools/types.ts +345 -319
  37. package/src/filling/context.ts +65 -0
  38. package/src/filling/filling.ts +336 -14
  39. package/src/filling/index.ts +5 -1
  40. package/src/filling/session.ts +216 -0
  41. package/src/filling/ws.ts +78 -0
  42. package/src/generator/generate.ts +2 -2
  43. package/src/guard/auto-correct.ts +0 -29
  44. package/src/guard/check.ts +14 -31
  45. package/src/guard/presets/index.ts +296 -294
  46. package/src/guard/rules.ts +15 -19
  47. package/src/guard/validator.ts +834 -834
  48. package/src/index.ts +5 -1
  49. package/src/island/index.ts +373 -304
  50. package/src/kitchen/api/contract-api.ts +225 -0
  51. package/src/kitchen/api/diff-parser.ts +108 -0
  52. package/src/kitchen/api/file-api.ts +273 -0
  53. package/src/kitchen/api/guard-api.ts +83 -0
  54. package/src/kitchen/api/guard-decisions.ts +100 -0
  55. package/src/kitchen/api/routes-api.ts +50 -0
  56. package/src/kitchen/index.ts +21 -0
  57. package/src/kitchen/kitchen-handler.ts +256 -0
  58. package/src/kitchen/kitchen-ui.ts +1732 -0
  59. package/src/kitchen/stream/activity-sse.ts +145 -0
  60. package/src/kitchen/stream/file-tailer.ts +99 -0
  61. package/src/middleware/compress.ts +62 -0
  62. package/src/middleware/cors.ts +47 -0
  63. package/src/middleware/index.ts +10 -0
  64. package/src/middleware/jwt.ts +134 -0
  65. package/src/middleware/logger.ts +58 -0
  66. package/src/middleware/timeout.ts +55 -0
  67. package/src/paths.ts +0 -4
  68. package/src/plugins/hooks.ts +64 -0
  69. package/src/plugins/index.ts +3 -0
  70. package/src/plugins/types.ts +5 -0
  71. package/src/report/build.ts +0 -6
  72. package/src/resource/__tests__/backward-compat.test.ts +0 -1
  73. package/src/router/fs-patterns.ts +11 -1
  74. package/src/router/fs-routes.ts +78 -14
  75. package/src/router/fs-scanner.ts +2 -2
  76. package/src/router/fs-types.ts +2 -1
  77. package/src/runtime/adapter-bun.ts +62 -0
  78. package/src/runtime/adapter.ts +47 -0
  79. package/src/runtime/cache.ts +310 -0
  80. package/src/runtime/handler.ts +65 -0
  81. package/src/runtime/image-handler.ts +195 -0
  82. package/src/runtime/index.ts +12 -0
  83. package/src/runtime/middleware.ts +263 -0
  84. package/src/runtime/server.ts +686 -92
  85. package/src/runtime/ssr.ts +55 -29
  86. package/src/runtime/streaming-ssr.ts +106 -82
  87. package/src/spec/index.ts +0 -1
  88. package/src/spec/schema.ts +1 -0
  89. package/src/testing/index.ts +144 -0
  90. package/src/watcher/watcher.ts +27 -1
  91. package/src/spec/lock.ts +0 -56
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Contract Playground API
3
+ *
4
+ * GET /__kitchen/api/contracts → list all contracts
5
+ * GET /__kitchen/api/contracts/:id → contract detail (with OpenAPI schema)
6
+ * POST /__kitchen/api/contracts/validate → validate input against contract
7
+ * GET /__kitchen/api/contracts/openapi → full OpenAPI 3.0.3 document (JSON)
8
+ * GET /__kitchen/api/contracts/openapi.yaml → OpenAPI YAML
9
+ */
10
+
11
+ import type { RoutesManifest, RouteSpec } from "../../spec/schema";
12
+ import {
13
+ generateOpenAPIDocument,
14
+ zodToOpenAPISchema,
15
+ openAPIToJSON,
16
+ openAPIToYAML,
17
+ } from "../../openapi/generator";
18
+ import path from "path";
19
+
20
+ export interface ContractListItem {
21
+ id: string;
22
+ pattern: string;
23
+ methods: string[];
24
+ description?: string;
25
+ }
26
+
27
+ export class ContractPlaygroundAPI {
28
+ private manifest: RoutesManifest;
29
+
30
+ constructor(
31
+ manifest: RoutesManifest,
32
+ private rootDir: string,
33
+ ) {
34
+ this.manifest = manifest;
35
+ }
36
+
37
+ updateManifest(manifest: RoutesManifest): void {
38
+ this.manifest = manifest;
39
+ }
40
+
41
+ /** GET /__kitchen/api/contracts */
42
+ async handleList(): Promise<Response> {
43
+ const contracts: ContractListItem[] = [];
44
+
45
+ for (const route of this.manifest.routes) {
46
+ if (!route.contractModule) continue;
47
+
48
+ const contract = await this.loadContract(route);
49
+ contracts.push({
50
+ id: route.id,
51
+ pattern: route.pattern,
52
+ methods: contract ? Object.keys(contract.request || {}) : route.methods || [],
53
+ description: contract?.description,
54
+ });
55
+ }
56
+
57
+ return Response.json({ contracts });
58
+ }
59
+
60
+ /** GET /__kitchen/api/contracts/:id */
61
+ async handleDetail(id: string): Promise<Response> {
62
+ const route = this.manifest.routes.find((r) => r.id === id);
63
+ if (!route || !route.contractModule) {
64
+ return Response.json({ error: "Contract not found" }, { status: 404 });
65
+ }
66
+
67
+ const contract = await this.loadContract(route);
68
+ if (!contract) {
69
+ return Response.json({ error: "Failed to load contract" }, { status: 500 });
70
+ }
71
+
72
+ // Build OpenAPI-like schema for each method
73
+ const schemas: Record<string, unknown> = {};
74
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
75
+
76
+ for (const method of HTTP_METHODS) {
77
+ const methodSchema = contract.request?.[method];
78
+ if (!methodSchema) continue;
79
+
80
+ const schema: Record<string, unknown> = {};
81
+ if (methodSchema.query) {
82
+ schema.query = zodToOpenAPISchema(methodSchema.query);
83
+ }
84
+ if (methodSchema.body) {
85
+ schema.body = zodToOpenAPISchema(methodSchema.body);
86
+ }
87
+ if (methodSchema.params) {
88
+ schema.params = zodToOpenAPISchema(methodSchema.params);
89
+ }
90
+ if (methodSchema.headers) {
91
+ schema.headers = zodToOpenAPISchema(methodSchema.headers);
92
+ }
93
+ schemas[method] = schema;
94
+ }
95
+
96
+ // Response schemas
97
+ const responseSchemas: Record<string, unknown> = {};
98
+ for (const [status, resSchema] of Object.entries(contract.response || {})) {
99
+ if (resSchema && typeof resSchema === "object" && "schema" in resSchema) {
100
+ responseSchemas[status] = zodToOpenAPISchema((resSchema as any).schema);
101
+ } else if (resSchema) {
102
+ responseSchemas[status] = zodToOpenAPISchema(resSchema as any);
103
+ }
104
+ }
105
+
106
+ return Response.json({
107
+ id: route.id,
108
+ pattern: route.pattern,
109
+ description: contract.description,
110
+ tags: contract.tags,
111
+ normalize: contract.normalize,
112
+ request: schemas,
113
+ response: responseSchemas,
114
+ });
115
+ }
116
+
117
+ /** POST /__kitchen/api/contracts/validate */
118
+ async handleValidate(req: Request): Promise<Response> {
119
+ try {
120
+ const body = await req.json();
121
+ const { contractId, method, input } = body as {
122
+ contractId: string;
123
+ method: string;
124
+ input: { query?: unknown; body?: unknown; params?: unknown };
125
+ };
126
+
127
+ if (!contractId || !method) {
128
+ return Response.json(
129
+ { error: "Missing contractId or method" },
130
+ { status: 400 },
131
+ );
132
+ }
133
+
134
+ const route = this.manifest.routes.find((r) => r.id === contractId);
135
+ if (!route || !route.contractModule) {
136
+ return Response.json(
137
+ { error: "Contract not found" },
138
+ { status: 404 },
139
+ );
140
+ }
141
+
142
+ const contract = await this.loadContract(route);
143
+ if (!contract) {
144
+ return Response.json(
145
+ { error: "Failed to load contract" },
146
+ { status: 500 },
147
+ );
148
+ }
149
+
150
+ const methodSchema = contract.request?.[method];
151
+ if (!methodSchema) {
152
+ return Response.json(
153
+ { error: `Method ${method} not defined in contract` },
154
+ { status: 400 },
155
+ );
156
+ }
157
+
158
+ const errors: Array<{ field: string; issues: unknown[] }> = [];
159
+
160
+ // Validate query
161
+ if (methodSchema.query && input?.query !== undefined) {
162
+ const result = methodSchema.query.safeParse(input.query);
163
+ if (!result.success) {
164
+ errors.push({ field: "query", issues: result.error.issues });
165
+ }
166
+ }
167
+
168
+ // Validate body
169
+ if (methodSchema.body && input?.body !== undefined) {
170
+ const result = methodSchema.body.safeParse(input.body);
171
+ if (!result.success) {
172
+ errors.push({ field: "body", issues: result.error.issues });
173
+ }
174
+ }
175
+
176
+ // Validate params
177
+ if (methodSchema.params && input?.params !== undefined) {
178
+ const result = methodSchema.params.safeParse(input.params);
179
+ if (!result.success) {
180
+ errors.push({ field: "params", issues: result.error.issues });
181
+ }
182
+ }
183
+
184
+ if (errors.length > 0) {
185
+ return Response.json({ valid: false, errors });
186
+ }
187
+
188
+ return Response.json({ valid: true });
189
+ } catch (e) {
190
+ return Response.json(
191
+ { error: "Invalid request body" },
192
+ { status: 400 },
193
+ );
194
+ }
195
+ }
196
+
197
+ /** GET /__kitchen/api/contracts/openapi */
198
+ async handleOpenAPI(): Promise<Response> {
199
+ const doc = await generateOpenAPIDocument(this.manifest, this.rootDir);
200
+ return new Response(openAPIToJSON(doc), {
201
+ headers: { "Content-Type": "application/json; charset=utf-8" },
202
+ });
203
+ }
204
+
205
+ /** GET /__kitchen/api/contracts/openapi.yaml */
206
+ async handleOpenAPIYAML(): Promise<Response> {
207
+ const doc = await generateOpenAPIDocument(this.manifest, this.rootDir);
208
+ return new Response(openAPIToYAML(doc), {
209
+ headers: { "Content-Type": "text/yaml; charset=utf-8" },
210
+ });
211
+ }
212
+
213
+ // ────────────────────────────────────────────────
214
+
215
+ private async loadContract(route: RouteSpec): Promise<any | null> {
216
+ if (!route.contractModule) return null;
217
+ try {
218
+ const fullPath = path.join(this.rootDir, route.contractModule);
219
+ const module = await import(fullPath);
220
+ return module.default;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Unified Diff Parser
3
+ *
4
+ * Parses git diff output into structured DiffHunk/DiffLine objects
5
+ * for rendering in the Kitchen Preview panel.
6
+ */
7
+
8
+ export interface DiffLine {
9
+ type: "add" | "remove" | "context";
10
+ content: string;
11
+ oldLine?: number;
12
+ newLine?: number;
13
+ }
14
+
15
+ export interface DiffHunk {
16
+ header: string;
17
+ lines: DiffLine[];
18
+ }
19
+
20
+ export interface FileDiff {
21
+ filePath: string;
22
+ hunks: DiffHunk[];
23
+ additions: number;
24
+ deletions: number;
25
+ isNew: boolean;
26
+ }
27
+
28
+ /**
29
+ * Parse unified diff output for a single file.
30
+ */
31
+ export function parseUnifiedDiff(raw: string, filePath: string): FileDiff {
32
+ const result: FileDiff = {
33
+ filePath,
34
+ hunks: [],
35
+ additions: 0,
36
+ deletions: 0,
37
+ isNew: false,
38
+ };
39
+
40
+ if (!raw.trim()) return result;
41
+
42
+ const lines = raw.split("\n");
43
+ let currentHunk: DiffHunk | null = null;
44
+ let oldLine = 0;
45
+ let newLine = 0;
46
+
47
+ for (const line of lines) {
48
+ // Detect new file
49
+ if (line.startsWith("new file mode")) {
50
+ result.isNew = true;
51
+ continue;
52
+ }
53
+
54
+ // Skip diff headers
55
+ if (
56
+ line.startsWith("diff ") ||
57
+ line.startsWith("index ") ||
58
+ line.startsWith("--- ") ||
59
+ line.startsWith("+++ ") ||
60
+ line.startsWith("Binary ")
61
+ ) {
62
+ continue;
63
+ }
64
+
65
+ // Hunk header: @@ -oldStart,oldCount +newStart,newCount @@
66
+ const hunkMatch = line.match(
67
+ /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)$/,
68
+ );
69
+ if (hunkMatch) {
70
+ currentHunk = {
71
+ header: line,
72
+ lines: [],
73
+ };
74
+ result.hunks.push(currentHunk);
75
+ oldLine = parseInt(hunkMatch[1], 10);
76
+ newLine = parseInt(hunkMatch[2], 10);
77
+ continue;
78
+ }
79
+
80
+ if (!currentHunk) continue;
81
+
82
+ if (line.startsWith("+")) {
83
+ currentHunk.lines.push({
84
+ type: "add",
85
+ content: line.slice(1),
86
+ newLine: newLine++,
87
+ });
88
+ result.additions++;
89
+ } else if (line.startsWith("-")) {
90
+ currentHunk.lines.push({
91
+ type: "remove",
92
+ content: line.slice(1),
93
+ oldLine: oldLine++,
94
+ });
95
+ result.deletions++;
96
+ } else if (line.startsWith(" ")) {
97
+ currentHunk.lines.push({
98
+ type: "context",
99
+ content: line.slice(1),
100
+ oldLine: oldLine++,
101
+ newLine: newLine++,
102
+ });
103
+ }
104
+ // Skip "" and other non-standard lines
105
+ }
106
+
107
+ return result;
108
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * File API - Provides file read, diff, and recent changes for Kitchen Preview.
3
+ *
4
+ * GET /__kitchen/api/file?path=... → file content
5
+ * GET /__kitchen/api/file/diff?path=... → git diff for file
6
+ * GET /__kitchen/api/file/changes → git status (recent changes)
7
+ */
8
+
9
+ import path from "path";
10
+ import fs from "fs";
11
+ import { parseUnifiedDiff, type FileDiff } from "./diff-parser";
12
+
13
+ export interface RecentFileChange {
14
+ filePath: string;
15
+ status: "added" | "modified" | "deleted" | "renamed" | "untracked";
16
+ }
17
+
18
+ export class FileAPI {
19
+ private gitRoot: string | null | undefined;
20
+
21
+ constructor(private rootDir: string) {}
22
+
23
+ /**
24
+ * GET /__kitchen/api/file?path=...
25
+ * Read file content with language detection.
26
+ */
27
+ async handleReadFile(url: URL): Promise<Response> {
28
+ const filePath = url.searchParams.get("path");
29
+ if (!filePath) {
30
+ return Response.json(
31
+ { error: "Missing 'path' query parameter" },
32
+ { status: 400 },
33
+ );
34
+ }
35
+
36
+ const resolved = this.validatePath(filePath);
37
+ if (!resolved) {
38
+ return Response.json(
39
+ { error: "Path outside project root" },
40
+ { status: 403 },
41
+ );
42
+ }
43
+
44
+ try {
45
+ const file = Bun.file(resolved);
46
+ const exists = await file.exists();
47
+ if (!exists) {
48
+ return Response.json({ error: "File not found" }, { status: 404 });
49
+ }
50
+
51
+ const content = await file.text();
52
+ const language = detectLanguage(filePath);
53
+
54
+ return Response.json({ filePath, content, language });
55
+ } catch (error) {
56
+ return Response.json(
57
+ { error: "Failed to read file" },
58
+ { status: 500 },
59
+ );
60
+ }
61
+ }
62
+
63
+ /**
64
+ * GET /__kitchen/api/file/diff?path=...
65
+ * Get git diff for a specific file.
66
+ */
67
+ async handleFileDiff(url: URL): Promise<Response> {
68
+ const filePath = url.searchParams.get("path");
69
+ if (!filePath) {
70
+ return Response.json(
71
+ { error: "Missing 'path' query parameter" },
72
+ { status: 400 },
73
+ );
74
+ }
75
+
76
+ const resolved = this.validatePath(filePath);
77
+ if (!resolved) {
78
+ return Response.json(
79
+ { error: "Path outside project root" },
80
+ { status: 403 },
81
+ );
82
+ }
83
+
84
+ try {
85
+ const diff = await this.getFileDiff(filePath, resolved);
86
+ return Response.json(diff);
87
+ } catch (error) {
88
+ return Response.json(
89
+ { error: "Failed to get diff" },
90
+ { status: 500 },
91
+ );
92
+ }
93
+ }
94
+
95
+ /**
96
+ * GET /__kitchen/api/file/changes
97
+ * List recently changed files via git status.
98
+ */
99
+ async handleRecentChanges(): Promise<Response> {
100
+ try {
101
+ const changes = await this.getGitStatus();
102
+ return Response.json({ changes });
103
+ } catch (error) {
104
+ return Response.json(
105
+ { error: "Failed to get changes" },
106
+ { status: 500 },
107
+ );
108
+ }
109
+ }
110
+
111
+ // ────────────────────────────────────────────────
112
+
113
+ private validatePath(filePath: string): string | null {
114
+ const resolved = path.resolve(this.rootDir, filePath);
115
+ if (!resolved.startsWith(this.rootDir + path.sep) && resolved !== this.rootDir) {
116
+ return null;
117
+ }
118
+ return resolved;
119
+ }
120
+
121
+ private getGitRoot(): string | null {
122
+ if (this.gitRoot !== undefined) {
123
+ return this.gitRoot;
124
+ }
125
+
126
+ let current = path.resolve(this.rootDir);
127
+ while (true) {
128
+ if (fs.existsSync(path.join(current, ".git"))) {
129
+ this.gitRoot = current;
130
+ return current;
131
+ }
132
+
133
+ const parent = path.dirname(current);
134
+ if (parent === current) {
135
+ this.gitRoot = null;
136
+ return null;
137
+ }
138
+ current = parent;
139
+ }
140
+ }
141
+
142
+ private async runGit(args: string[], cwd: string): Promise<string> {
143
+ const proc = Bun.spawn(["git", ...args], {
144
+ cwd,
145
+ stdout: "pipe",
146
+ stderr: "pipe",
147
+ });
148
+ const stdout = await new Response(proc.stdout).text();
149
+ const stderr = await new Response(proc.stderr).text();
150
+ const exitCode = await proc.exited;
151
+
152
+ if (exitCode !== 0) {
153
+ throw new Error(stderr || stdout || `git ${args.join(" ")} failed`);
154
+ }
155
+
156
+ return stdout;
157
+ }
158
+
159
+ private getRepoRelativePath(resolvedPath: string, gitRoot: string): string {
160
+ return path.relative(gitRoot, resolvedPath).replace(/\\/g, "/");
161
+ }
162
+
163
+ private getProjectRelativePath(repoRelativePath: string, gitRoot: string): string {
164
+ const absolutePath = path.resolve(gitRoot, repoRelativePath.replace(/\//g, path.sep));
165
+ return path.relative(this.rootDir, absolutePath).replace(/\\/g, "/");
166
+ }
167
+
168
+ private async getFileDiff(filePath: string, resolvedPath: string): Promise<FileDiff> {
169
+ const gitRoot = this.getGitRoot();
170
+ if (!gitRoot) {
171
+ return this.createSyntheticDiff(filePath, resolvedPath);
172
+ }
173
+
174
+ const repoRelativePath = this.getRepoRelativePath(resolvedPath, gitRoot);
175
+ const stdout = await this.runGit(["diff", "--", repoRelativePath], gitRoot);
176
+ if (stdout.trim()) {
177
+ return parseUnifiedDiff(stdout, filePath);
178
+ }
179
+
180
+ if (!fs.existsSync(resolvedPath)) {
181
+ return parseUnifiedDiff("", filePath);
182
+ }
183
+
184
+ const status = await this.runGit(["status", "--porcelain", "--", repoRelativePath], gitRoot);
185
+ if (status.trim().startsWith("??")) {
186
+ return this.createSyntheticDiff(filePath, resolvedPath);
187
+ }
188
+
189
+ return parseUnifiedDiff("", filePath);
190
+ }
191
+
192
+ private createSyntheticDiff(filePath: string, resolvedPath: string): FileDiff {
193
+ // Bun.file().text() is async, but synthetic diffs are only used in non-git fallback.
194
+ // Use fs for synchronous fallback to keep the response fast.
195
+ const normalized = fs.readFileSync(resolvedPath, "utf-8").replace(/\r\n/g, "\n");
196
+ if (!normalized) {
197
+ return parseUnifiedDiff("", filePath);
198
+ }
199
+
200
+ const lines = normalized.split("\n");
201
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
202
+ lines.pop();
203
+ }
204
+
205
+ const raw = [
206
+ "new file mode 100644",
207
+ `@@ -0,0 +1,${lines.length} @@`,
208
+ ...lines.map((line) => `+${line}`),
209
+ ].join("\n");
210
+
211
+ return parseUnifiedDiff(raw, filePath);
212
+ }
213
+
214
+ private async getGitStatus(): Promise<RecentFileChange[]> {
215
+ const gitRoot = this.getGitRoot();
216
+ if (!gitRoot) {
217
+ return [];
218
+ }
219
+
220
+ const repoRelativeRoot = this.getRepoRelativePath(this.rootDir, gitRoot);
221
+ const args = ["status", "--porcelain"];
222
+ if (repoRelativeRoot && repoRelativeRoot !== ".") {
223
+ args.push("--", repoRelativeRoot);
224
+ }
225
+
226
+ const stdout = await this.runGit(args, gitRoot);
227
+
228
+ const changes: RecentFileChange[] = [];
229
+ for (const line of stdout.split("\n")) {
230
+ if (!line.trim()) continue;
231
+
232
+ const statusCode = line.substring(0, 2);
233
+ const gitFilePath = line.substring(3).trim();
234
+ const filePath = this.getProjectRelativePath(gitFilePath, gitRoot);
235
+
236
+ changes.push({
237
+ filePath,
238
+ status: parseGitStatus(statusCode),
239
+ });
240
+ }
241
+
242
+ return changes;
243
+ }
244
+ }
245
+
246
+ function parseGitStatus(code: string): RecentFileChange["status"] {
247
+ const x = code[0];
248
+ const y = code[1];
249
+
250
+ if (x === "?" || y === "?") return "untracked";
251
+ if (x === "A" || y === "A") return "added";
252
+ if (x === "D" || y === "D") return "deleted";
253
+ if (x === "R" || y === "R") return "renamed";
254
+ return "modified";
255
+ }
256
+
257
+ function detectLanguage(filePath: string): string {
258
+ const ext = path.extname(filePath).toLowerCase();
259
+ const langMap: Record<string, string> = {
260
+ ".ts": "typescript",
261
+ ".tsx": "typescript",
262
+ ".js": "javascript",
263
+ ".jsx": "javascript",
264
+ ".json": "json",
265
+ ".css": "css",
266
+ ".html": "html",
267
+ ".md": "markdown",
268
+ ".yaml": "yaml",
269
+ ".yml": "yaml",
270
+ ".toml": "toml",
271
+ };
272
+ return langMap[ext] || "text";
273
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Guard API - Exposes architecture guard data to Kitchen UI.
3
+ *
4
+ * GET /__kitchen/api/guard → latest cached violation report
5
+ * POST /__kitchen/api/guard/scan → trigger full directory scan
6
+ */
7
+
8
+ import type { GuardConfig, ViolationReport } from "../../guard/types";
9
+ import { checkDirectory } from "../../guard/watcher";
10
+
11
+ export class GuardAPI {
12
+ private cachedReport: ViolationReport | null = null;
13
+ private scanning = false;
14
+
15
+ constructor(
16
+ private config: GuardConfig | null,
17
+ private rootDir: string,
18
+ ) {}
19
+
20
+ /** Update config at runtime (e.g., when mandu.config.ts changes) */
21
+ updateConfig(config: GuardConfig | null): void {
22
+ this.config = config;
23
+ }
24
+
25
+ /** Handle GET /__kitchen/api/guard */
26
+ handleGetReport(): Response {
27
+ if (!this.config) {
28
+ return Response.json(
29
+ { enabled: false, message: "Guard is not configured" },
30
+ { status: 200 },
31
+ );
32
+ }
33
+
34
+ if (!this.cachedReport) {
35
+ return Response.json({
36
+ enabled: true,
37
+ preset: this.config.preset,
38
+ report: null,
39
+ message: "No scan has been run yet. POST /__kitchen/api/guard/scan to trigger.",
40
+ });
41
+ }
42
+
43
+ return Response.json({
44
+ enabled: true,
45
+ preset: this.config.preset,
46
+ report: this.cachedReport,
47
+ });
48
+ }
49
+
50
+ /** Handle POST /__kitchen/api/guard/scan */
51
+ async handleScan(): Promise<Response> {
52
+ if (!this.config) {
53
+ return Response.json(
54
+ { enabled: false, message: "Guard is not configured" },
55
+ { status: 200 },
56
+ );
57
+ }
58
+
59
+ if (this.scanning) {
60
+ return Response.json(
61
+ { message: "Scan already in progress" },
62
+ { status: 409 },
63
+ );
64
+ }
65
+
66
+ this.scanning = true;
67
+ try {
68
+ this.cachedReport = await checkDirectory(this.config, this.rootDir);
69
+ return Response.json({
70
+ enabled: true,
71
+ preset: this.config.preset,
72
+ report: this.cachedReport,
73
+ });
74
+ } finally {
75
+ this.scanning = false;
76
+ }
77
+ }
78
+
79
+ /** Called by guard watcher's onViolation to push incremental updates */
80
+ pushViolationReport(report: ViolationReport): void {
81
+ this.cachedReport = report;
82
+ }
83
+ }