@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.
- package/README.ko.md +0 -14
- package/package.json +4 -1
- package/src/brain/architecture/analyzer.ts +4 -4
- package/src/brain/doctor/analyzer.ts +18 -14
- package/src/bundler/build.test.ts +127 -0
- package/src/bundler/build.ts +291 -113
- package/src/bundler/css.ts +20 -5
- package/src/bundler/dev.ts +55 -2
- package/src/bundler/prerender.ts +195 -0
- package/src/change/snapshot.ts +4 -23
- package/src/change/types.ts +2 -3
- package/src/client/Form.tsx +105 -0
- package/src/client/__tests__/use-sse.test.ts +153 -0
- package/src/client/hooks.ts +105 -6
- package/src/client/index.ts +35 -6
- package/src/client/router.ts +670 -433
- package/src/client/rpc.ts +140 -0
- package/src/client/runtime.ts +24 -21
- package/src/client/use-fetch.ts +239 -0
- package/src/client/use-head.ts +197 -0
- package/src/client/use-sse.ts +378 -0
- package/src/components/Image.tsx +162 -0
- package/src/config/mandu.ts +5 -0
- package/src/config/validate.ts +34 -0
- package/src/content/index.ts +5 -1
- package/src/devtools/client/catchers/error-catcher.ts +17 -0
- package/src/devtools/client/catchers/network-proxy.ts +390 -367
- package/src/devtools/client/components/kitchen-root.tsx +479 -467
- package/src/devtools/client/components/panel/diff-viewer.tsx +219 -0
- package/src/devtools/client/components/panel/guard-panel.tsx +374 -244
- package/src/devtools/client/components/panel/index.ts +45 -32
- package/src/devtools/client/components/panel/panel-container.tsx +332 -312
- package/src/devtools/client/components/panel/preview-panel.tsx +188 -0
- package/src/devtools/client/state-manager.ts +535 -478
- package/src/devtools/design-tokens.ts +265 -264
- package/src/devtools/types.ts +345 -319
- package/src/filling/context.ts +65 -0
- package/src/filling/filling.ts +336 -14
- package/src/filling/index.ts +5 -1
- package/src/filling/session.ts +216 -0
- package/src/filling/ws.ts +78 -0
- package/src/generator/generate.ts +2 -2
- package/src/guard/auto-correct.ts +0 -29
- package/src/guard/check.ts +14 -31
- package/src/guard/presets/index.ts +296 -294
- package/src/guard/rules.ts +15 -19
- package/src/guard/validator.ts +834 -834
- package/src/index.ts +5 -1
- package/src/island/index.ts +373 -304
- package/src/kitchen/api/contract-api.ts +225 -0
- package/src/kitchen/api/diff-parser.ts +108 -0
- package/src/kitchen/api/file-api.ts +273 -0
- package/src/kitchen/api/guard-api.ts +83 -0
- package/src/kitchen/api/guard-decisions.ts +100 -0
- package/src/kitchen/api/routes-api.ts +50 -0
- package/src/kitchen/index.ts +21 -0
- package/src/kitchen/kitchen-handler.ts +256 -0
- package/src/kitchen/kitchen-ui.ts +1732 -0
- package/src/kitchen/stream/activity-sse.ts +145 -0
- package/src/kitchen/stream/file-tailer.ts +99 -0
- package/src/middleware/compress.ts +62 -0
- package/src/middleware/cors.ts +47 -0
- package/src/middleware/index.ts +10 -0
- package/src/middleware/jwt.ts +134 -0
- package/src/middleware/logger.ts +58 -0
- package/src/middleware/timeout.ts +55 -0
- package/src/paths.ts +0 -4
- package/src/plugins/hooks.ts +64 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/types.ts +5 -0
- package/src/report/build.ts +0 -6
- package/src/resource/__tests__/backward-compat.test.ts +0 -1
- package/src/router/fs-patterns.ts +11 -1
- package/src/router/fs-routes.ts +78 -14
- package/src/router/fs-scanner.ts +2 -2
- package/src/router/fs-types.ts +2 -1
- package/src/runtime/adapter-bun.ts +62 -0
- package/src/runtime/adapter.ts +47 -0
- package/src/runtime/cache.ts +310 -0
- package/src/runtime/handler.ts +65 -0
- package/src/runtime/image-handler.ts +195 -0
- package/src/runtime/index.ts +12 -0
- package/src/runtime/middleware.ts +263 -0
- package/src/runtime/server.ts +686 -92
- package/src/runtime/ssr.ts +55 -29
- package/src/runtime/streaming-ssr.ts +106 -82
- package/src/spec/index.ts +0 -1
- package/src/spec/schema.ts +1 -0
- package/src/testing/index.ts +144 -0
- package/src/watcher/watcher.ts +27 -1
- 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
|
+
}
|