@mandujs/core 0.9.1 → 0.9.3

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.
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Brain v0.2 - Architecture Guard Types
3
+ *
4
+ * 프로젝트 아키텍처 규칙 정의 및 검증을 위한 타입
5
+ */
6
+
7
+ /**
8
+ * 폴더 역할 정의
9
+ */
10
+ export interface FolderRule {
11
+ /** 폴더 경로 패턴 (glob) */
12
+ pattern: string;
13
+ /** 폴더 설명/역할 */
14
+ description: string;
15
+ /** 허용되는 파일 패턴 */
16
+ allowedFiles?: string[];
17
+ /** 금지되는 파일 패턴 */
18
+ forbiddenFiles?: string[];
19
+ /** 이 폴더에서 허용되는 export */
20
+ allowedExports?: string[];
21
+ /** 수정 금지 여부 */
22
+ readonly?: boolean;
23
+ }
24
+
25
+ /**
26
+ * Import 규칙 정의
27
+ */
28
+ export interface ImportRule {
29
+ /** 적용 대상 파일 패턴 (glob) */
30
+ source: string;
31
+ /** 허용되는 import 패턴 */
32
+ allow?: string[];
33
+ /** 금지되는 import 패턴 */
34
+ forbid?: string[];
35
+ /** 규칙 설명 */
36
+ reason?: string;
37
+ }
38
+
39
+ /**
40
+ * 레이어 의존성 규칙
41
+ */
42
+ export interface LayerRule {
43
+ /** 레이어 이름 */
44
+ name: string;
45
+ /** 레이어에 속하는 폴더 패턴 */
46
+ folders: string[];
47
+ /** 의존 가능한 레이어 */
48
+ canDependOn: string[];
49
+ /** 의존 불가 레이어 */
50
+ cannotDependOn?: string[];
51
+ }
52
+
53
+ /**
54
+ * 네이밍 규칙
55
+ */
56
+ export interface NamingRule {
57
+ /** 적용 대상 폴더 패턴 */
58
+ folder: string;
59
+ /** 파일명 패턴 (정규식) */
60
+ filePattern: string;
61
+ /** 규칙 설명 */
62
+ description: string;
63
+ /** 예시 */
64
+ examples?: string[];
65
+ }
66
+
67
+ /**
68
+ * 아키텍처 설정
69
+ */
70
+ export interface ArchitectureConfig {
71
+ /** 폴더 규칙 */
72
+ folders?: Record<string, FolderRule | string>;
73
+ /** Import 규칙 */
74
+ imports?: ImportRule[];
75
+ /** 레이어 규칙 */
76
+ layers?: LayerRule[];
77
+ /** 네이밍 규칙 */
78
+ naming?: NamingRule[];
79
+ /** 커스텀 규칙 */
80
+ custom?: CustomRule[];
81
+ }
82
+
83
+ /**
84
+ * 커스텀 규칙
85
+ */
86
+ export interface CustomRule {
87
+ /** 규칙 ID */
88
+ id: string;
89
+ /** 규칙 설명 */
90
+ description: string;
91
+ /** 파일 패턴 */
92
+ pattern: string;
93
+ /** 검증 조건 (코드에 포함되어야 하는 패턴) */
94
+ mustContain?: string[];
95
+ /** 금지 조건 (코드에 포함되면 안 되는 패턴) */
96
+ mustNotContain?: string[];
97
+ }
98
+
99
+ /**
100
+ * 아키텍처 위반
101
+ */
102
+ export interface ArchitectureViolation {
103
+ /** 규칙 ID */
104
+ ruleId: string;
105
+ /** 규칙 타입 */
106
+ ruleType: "folder" | "import" | "layer" | "naming" | "custom";
107
+ /** 위반 파일 경로 */
108
+ file: string;
109
+ /** 위반 메시지 */
110
+ message: string;
111
+ /** 수정 제안 */
112
+ suggestion?: string;
113
+ /** 심각도 */
114
+ severity: "error" | "warning" | "info";
115
+ /** 위반 라인 (해당시) */
116
+ line?: number;
117
+ }
118
+
119
+ /**
120
+ * 위치 검증 요청
121
+ */
122
+ export interface CheckLocationRequest {
123
+ /** 검사할 파일 경로 */
124
+ path: string;
125
+ /** 파일 내용 (선택) */
126
+ content?: string;
127
+ /** 파일 타입 */
128
+ fileType?: "ts" | "tsx" | "js" | "jsx" | "json" | "other";
129
+ }
130
+
131
+ /**
132
+ * 위치 검증 결과
133
+ */
134
+ export interface CheckLocationResult {
135
+ /** 허용 여부 */
136
+ allowed: boolean;
137
+ /** 위반 목록 */
138
+ violations: ArchitectureViolation[];
139
+ /** LLM 제안 (활성화시) */
140
+ suggestion?: string;
141
+ /** 권장 경로 */
142
+ recommendedPath?: string;
143
+ }
144
+
145
+ /**
146
+ * Import 검증 요청
147
+ */
148
+ export interface CheckImportRequest {
149
+ /** 소스 파일 경로 */
150
+ sourceFile: string;
151
+ /** 검사할 import 문 */
152
+ imports: string[];
153
+ }
154
+
155
+ /**
156
+ * Import 검증 결과
157
+ */
158
+ export interface CheckImportResult {
159
+ /** 모든 import 허용 여부 */
160
+ allowed: boolean;
161
+ /** 위반된 import 목록 */
162
+ violations: Array<{
163
+ import: string;
164
+ reason: string;
165
+ suggestion?: string;
166
+ }>;
167
+ }
168
+
169
+ /**
170
+ * 프로젝트 구조 정보
171
+ */
172
+ export interface ProjectStructure {
173
+ /** 루트 디렉토리 */
174
+ rootDir: string;
175
+ /** 폴더 트리 */
176
+ folders: FolderInfo[];
177
+ /** 아키텍처 설정 */
178
+ config: ArchitectureConfig;
179
+ /** 인덱싱 시간 */
180
+ indexedAt: string;
181
+ }
182
+
183
+ /**
184
+ * 폴더 정보
185
+ */
186
+ export interface FolderInfo {
187
+ /** 경로 */
188
+ path: string;
189
+ /** 역할 설명 */
190
+ description?: string;
191
+ /** 파일 수 */
192
+ fileCount: number;
193
+ /** 하위 폴더 */
194
+ children?: FolderInfo[];
195
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Brain v0.1 - Module Exports
2
+ * Brain v0.2 - Module Exports
3
3
  *
4
- * Brain handles two responsibilities:
4
+ * Brain handles three responsibilities:
5
5
  * 1. Doctor (error recovery): Guard failure analysis + minimal patch suggestions
6
6
  * 2. Watch (error prevention): File change warnings (no blocking)
7
+ * 3. Architecture (structure enforcement): Project structure validation for coding agents
7
8
  */
8
9
 
9
10
  // Types
@@ -43,3 +44,6 @@ export {
43
44
 
44
45
  // Doctor
45
46
  export * from "./doctor";
47
+
48
+ // Architecture (v0.2)
49
+ export * from "./architecture";
@@ -103,8 +103,9 @@ import { Link, NavLink } from "./Link";
103
103
  /**
104
104
  * Mandu Client namespace
105
105
  * v0.8.0: Hydration은 자동으로 처리됨 (generateRuntimeSource에서 생성)
106
+ * Note: Use `ManduClient` to avoid conflict with other Mandu exports
106
107
  */
107
- export const Mandu = {
108
+ export const ManduClient = {
108
109
  /**
109
110
  * Create an island component
110
111
  * @see island
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Mandu Contract Client Tests
3
+ *
4
+ * 클라이언트 타입 추론 및 기능 테스트
5
+ */
6
+
7
+ import { describe, it, expect, mock } from "bun:test";
8
+ import { z } from "zod";
9
+ import { Mandu, createClient, contractFetch } from "./index";
10
+
11
+ // === Test Contract ===
12
+ const testContract = Mandu.contract({
13
+ description: "Test API",
14
+ tags: ["test"],
15
+ request: {
16
+ GET: {
17
+ query: z.object({
18
+ page: z.coerce.number().default(1),
19
+ search: z.string().optional(),
20
+ }),
21
+ },
22
+ POST: {
23
+ body: z.object({
24
+ name: z.string(),
25
+ email: z.string().email(),
26
+ }),
27
+ },
28
+ PUT: {
29
+ params: z.object({
30
+ id: z.string(),
31
+ }),
32
+ body: z.object({
33
+ name: z.string().optional(),
34
+ }),
35
+ },
36
+ DELETE: {
37
+ params: z.object({
38
+ id: z.string(),
39
+ }),
40
+ },
41
+ },
42
+ response: {
43
+ 200: z.object({
44
+ data: z.array(
45
+ z.object({
46
+ id: z.string(),
47
+ name: z.string(),
48
+ })
49
+ ),
50
+ total: z.number(),
51
+ }),
52
+ 201: z.object({
53
+ data: z.object({
54
+ id: z.string(),
55
+ name: z.string(),
56
+ email: z.string(),
57
+ }),
58
+ }),
59
+ 204: z.undefined(),
60
+ 404: z.object({
61
+ error: z.string(),
62
+ }),
63
+ },
64
+ });
65
+
66
+ describe("Contract Client", () => {
67
+ it("should create a client with all HTTP methods", () => {
68
+ const client = createClient(testContract, {
69
+ baseUrl: "http://localhost:3000/api/test",
70
+ });
71
+
72
+ expect(client.GET).toBeDefined();
73
+ expect(client.POST).toBeDefined();
74
+ expect(client.PUT).toBeDefined();
75
+ expect(client.DELETE).toBeDefined();
76
+ expect(typeof client.GET).toBe("function");
77
+ expect(typeof client.POST).toBe("function");
78
+ });
79
+
80
+ it("should build query string correctly", async () => {
81
+ let capturedUrl = "";
82
+
83
+ const mockFetch = mock(async (url: string, _options: RequestInit) => {
84
+ capturedUrl = url;
85
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
86
+ status: 200,
87
+ headers: { "Content-Type": "application/json" },
88
+ });
89
+ });
90
+
91
+ const client = createClient(testContract, {
92
+ baseUrl: "http://localhost:3000/api/test",
93
+ fetch: mockFetch as unknown as typeof fetch,
94
+ });
95
+
96
+ await client.GET({ query: { page: 2, search: "hello" } });
97
+
98
+ expect(capturedUrl).toContain("page=2");
99
+ expect(capturedUrl).toContain("search=hello");
100
+ });
101
+
102
+ it("should send JSON body for POST requests", async () => {
103
+ let capturedBody = "";
104
+ let capturedContentType = "";
105
+
106
+ const mockFetch = mock(async (_url: string, options: RequestInit) => {
107
+ capturedBody = options.body as string;
108
+ capturedContentType =
109
+ (options.headers as Record<string, string>)["Content-Type"] || "";
110
+ return new Response(
111
+ JSON.stringify({
112
+ data: { id: "1", name: "Test", email: "test@example.com" },
113
+ }),
114
+ {
115
+ status: 201,
116
+ headers: { "Content-Type": "application/json" },
117
+ }
118
+ );
119
+ });
120
+
121
+ const client = createClient(testContract, {
122
+ baseUrl: "http://localhost:3000/api/test",
123
+ fetch: mockFetch as unknown as typeof fetch,
124
+ });
125
+
126
+ await client.POST({ body: { name: "Test", email: "test@example.com" } });
127
+
128
+ expect(capturedContentType).toBe("application/json");
129
+ expect(JSON.parse(capturedBody)).toEqual({
130
+ name: "Test",
131
+ email: "test@example.com",
132
+ });
133
+ });
134
+
135
+ it("should parse JSON response", async () => {
136
+ const mockData = {
137
+ data: [
138
+ { id: "1", name: "User 1" },
139
+ { id: "2", name: "User 2" },
140
+ ],
141
+ total: 2,
142
+ };
143
+
144
+ const mockFetch = mock(async () => {
145
+ return new Response(JSON.stringify(mockData), {
146
+ status: 200,
147
+ headers: { "Content-Type": "application/json" },
148
+ });
149
+ });
150
+
151
+ const client = createClient(testContract, {
152
+ baseUrl: "http://localhost:3000/api/test",
153
+ fetch: mockFetch as unknown as typeof fetch,
154
+ });
155
+
156
+ const result = await client.GET({ query: { page: 1 } });
157
+
158
+ expect(result.status).toBe(200);
159
+ expect(result.ok).toBe(true);
160
+ expect(result.data).toEqual(mockData);
161
+ });
162
+
163
+ it("should include default headers", async () => {
164
+ let capturedHeaders: Record<string, string> = {};
165
+
166
+ const mockFetch = mock(async (_url: string, options: RequestInit) => {
167
+ capturedHeaders = options.headers as Record<string, string>;
168
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
169
+ status: 200,
170
+ headers: { "Content-Type": "application/json" },
171
+ });
172
+ });
173
+
174
+ const client = createClient(testContract, {
175
+ baseUrl: "http://localhost:3000/api/test",
176
+ headers: {
177
+ Authorization: "Bearer token123",
178
+ "X-Custom-Header": "custom-value",
179
+ },
180
+ fetch: mockFetch as unknown as typeof fetch,
181
+ });
182
+
183
+ await client.GET();
184
+
185
+ expect(capturedHeaders["Authorization"]).toBe("Bearer token123");
186
+ expect(capturedHeaders["X-Custom-Header"]).toBe("custom-value");
187
+ });
188
+
189
+ it("should allow per-request headers", async () => {
190
+ let capturedHeaders: Record<string, string> = {};
191
+
192
+ const mockFetch = mock(async (_url: string, options: RequestInit) => {
193
+ capturedHeaders = options.headers as Record<string, string>;
194
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
195
+ status: 200,
196
+ headers: { "Content-Type": "application/json" },
197
+ });
198
+ });
199
+
200
+ const client = createClient(testContract, {
201
+ baseUrl: "http://localhost:3000/api/test",
202
+ headers: { "X-Default": "default" },
203
+ fetch: mockFetch as unknown as typeof fetch,
204
+ });
205
+
206
+ await client.GET({
207
+ headers: { "X-Custom": "per-request" },
208
+ });
209
+
210
+ expect(capturedHeaders["X-Default"]).toBe("default");
211
+ expect(capturedHeaders["X-Custom"]).toBe("per-request");
212
+ });
213
+ });
214
+
215
+ describe("contractFetch", () => {
216
+ it("should make type-safe fetch call", async () => {
217
+ const mockFetch = mock(async () => {
218
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
219
+ status: 200,
220
+ headers: { "Content-Type": "application/json" },
221
+ });
222
+ });
223
+
224
+ const result = await contractFetch(
225
+ testContract,
226
+ "GET",
227
+ "http://localhost:3000/api/test",
228
+ { query: { page: 1 } },
229
+ { fetch: mockFetch as unknown as typeof fetch }
230
+ );
231
+
232
+ expect(result.status).toBe(200);
233
+ expect(result.ok).toBe(true);
234
+ expect(mockFetch).toHaveBeenCalled();
235
+ });
236
+
237
+ it("should handle path parameters", async () => {
238
+ let capturedUrl = "";
239
+
240
+ const mockFetch = mock(async (url: string) => {
241
+ capturedUrl = url;
242
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
243
+ status: 200,
244
+ headers: { "Content-Type": "application/json" },
245
+ });
246
+ });
247
+
248
+ await contractFetch(
249
+ testContract,
250
+ "PUT",
251
+ "http://localhost:3000/api/test/:id",
252
+ { params: { id: "123" }, body: { name: "Updated" } },
253
+ { fetch: mockFetch as unknown as typeof fetch }
254
+ );
255
+
256
+ expect(capturedUrl).toBe("http://localhost:3000/api/test/123");
257
+ });
258
+ });
259
+
260
+ describe("Mandu.client", () => {
261
+ it("should be accessible via Mandu namespace", () => {
262
+ expect(Mandu.client).toBe(createClient);
263
+ });
264
+
265
+ it("should work via Mandu namespace", async () => {
266
+ const mockFetch = mock(async () => {
267
+ return new Response(JSON.stringify({ data: [], total: 0 }), {
268
+ status: 200,
269
+ headers: { "Content-Type": "application/json" },
270
+ });
271
+ });
272
+
273
+ const client = Mandu.client(testContract, {
274
+ baseUrl: "http://localhost:3000/api/test",
275
+ fetch: mockFetch as unknown as typeof fetch,
276
+ });
277
+
278
+ const result = await client.GET({ query: { page: 1 } });
279
+
280
+ expect(result.status).toBe(200);
281
+ expect(result.data).toEqual({ data: [], total: 0 });
282
+ });
283
+ });
284
+
285
+ describe("Mandu.fetch", () => {
286
+ it("should be accessible via Mandu namespace", () => {
287
+ expect(Mandu.fetch).toBe(contractFetch);
288
+ });
289
+ });
290
+
291
+ describe("Type Safety (Compile-time)", () => {
292
+ it("should enforce query types", () => {
293
+ // This test verifies that the type system is working
294
+ // If the types are wrong, this won't compile
295
+ const client = createClient(testContract, {
296
+ baseUrl: "http://localhost:3000/api/test",
297
+ });
298
+
299
+ // These are valid calls (would compile)
300
+ const _validGet = () => client.GET({ query: { page: 1 } });
301
+ const _validPost = () =>
302
+ client.POST({ body: { name: "Test", email: "test@example.com" } });
303
+
304
+ // Type-level assertions
305
+ expect(typeof client.GET).toBe("function");
306
+ expect(typeof client.POST).toBe("function");
307
+ });
308
+ });