@mandujs/core 0.8.2 → 0.9.0

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,268 @@
1
+ /**
2
+ * Brain v0.1 - Core Type Definitions
3
+ *
4
+ * Brain handles two responsibilities:
5
+ * 1. Doctor (error recovery): Guard failure analysis + minimal patch suggestions
6
+ * 2. Watch (error prevention): File change warnings (no blocking)
7
+ */
8
+
9
+ import type { GuardViolation } from "../guard/rules";
10
+ import type { RoutesManifest } from "../spec/schema";
11
+
12
+ // ========== LLM Adapter Types ==========
13
+
14
+ /**
15
+ * Message role for LLM conversations
16
+ */
17
+ export type MessageRole = "system" | "user" | "assistant";
18
+
19
+ /**
20
+ * Chat message structure
21
+ */
22
+ export interface ChatMessage {
23
+ role: MessageRole;
24
+ content: string;
25
+ }
26
+
27
+ /**
28
+ * LLM completion options
29
+ */
30
+ export interface CompletionOptions {
31
+ /** Temperature (0-1, lower = more deterministic) */
32
+ temperature?: number;
33
+ /** Maximum tokens to generate */
34
+ maxTokens?: number;
35
+ /** Stop sequences */
36
+ stop?: string[];
37
+ }
38
+
39
+ /**
40
+ * LLM completion result
41
+ */
42
+ export interface CompletionResult {
43
+ content: string;
44
+ /** Token usage (if available) */
45
+ usage?: {
46
+ promptTokens: number;
47
+ completionTokens: number;
48
+ totalTokens: number;
49
+ };
50
+ }
51
+
52
+ /**
53
+ * LLM adapter status
54
+ */
55
+ export interface AdapterStatus {
56
+ available: boolean;
57
+ model: string | null;
58
+ error?: string;
59
+ }
60
+
61
+ /**
62
+ * LLM adapter configuration
63
+ */
64
+ export interface AdapterConfig {
65
+ /** Base URL for the LLM API */
66
+ baseUrl: string;
67
+ /** Model name to use */
68
+ model: string;
69
+ /** Connection timeout in ms */
70
+ timeout?: number;
71
+ }
72
+
73
+ // ========== Doctor Types ==========
74
+
75
+ /**
76
+ * Patch suggestion from Doctor
77
+ */
78
+ export interface PatchSuggestion {
79
+ /** Target file path */
80
+ file: string;
81
+ /** Description of the change */
82
+ description: string;
83
+ /** Type of patch */
84
+ type: "add" | "modify" | "delete" | "command";
85
+ /** Content to add/modify (for add/modify types) */
86
+ content?: string;
87
+ /** Line number to modify (for modify type) */
88
+ line?: number;
89
+ /** Command to run (for command type) */
90
+ command?: string;
91
+ /** Confidence level (0-1) */
92
+ confidence: number;
93
+ }
94
+
95
+ /**
96
+ * Doctor analysis result
97
+ */
98
+ export interface DoctorAnalysis {
99
+ /** Original violations */
100
+ violations: GuardViolation[];
101
+ /** Root cause summary */
102
+ summary: string;
103
+ /** Detailed explanation */
104
+ explanation: string;
105
+ /** Suggested patches */
106
+ patches: PatchSuggestion[];
107
+ /** Whether LLM was used for analysis */
108
+ llmAssisted: boolean;
109
+ /** Recommended next command */
110
+ nextCommand?: string;
111
+ }
112
+
113
+ /**
114
+ * Doctor options
115
+ */
116
+ export interface DoctorOptions {
117
+ /** Whether to use LLM for enhanced analysis */
118
+ useLLM?: boolean;
119
+ /** Maximum patches to suggest */
120
+ maxPatches?: number;
121
+ /** Minimum confidence for suggestions */
122
+ minConfidence?: number;
123
+ }
124
+
125
+ // ========== Watch Types ==========
126
+
127
+ /**
128
+ * Architecture rule for Watch
129
+ */
130
+ export interface ArchRule {
131
+ /** Unique rule identifier */
132
+ id: string;
133
+ /** Human-readable rule name */
134
+ name: string;
135
+ /** Rule description */
136
+ description: string;
137
+ /** File pattern to match (glob) */
138
+ pattern: string;
139
+ /** Rule action */
140
+ action: "warn" | "error";
141
+ /** Warning message template */
142
+ message: string;
143
+ /** Optional: file extension requirement */
144
+ mustEndWith?: string;
145
+ /** Optional: forbidden imports */
146
+ forbiddenImports?: string[];
147
+ /** Optional: required patterns in content */
148
+ requiredPatterns?: RegExp[];
149
+ }
150
+
151
+ /**
152
+ * Watch warning
153
+ */
154
+ export interface WatchWarning {
155
+ /** Rule that triggered the warning */
156
+ ruleId: string;
157
+ /** Affected file path */
158
+ file: string;
159
+ /** Warning message */
160
+ message: string;
161
+ /** Timestamp */
162
+ timestamp: Date;
163
+ /** Event type that triggered the warning */
164
+ event: "create" | "modify" | "delete";
165
+ }
166
+
167
+ /**
168
+ * Watch status
169
+ */
170
+ export interface WatchStatus {
171
+ /** Whether watching is active */
172
+ active: boolean;
173
+ /** Root directory being watched */
174
+ rootDir: string | null;
175
+ /** Number of files being watched */
176
+ fileCount: number;
177
+ /** Recent warnings */
178
+ recentWarnings: WatchWarning[];
179
+ /** Start time */
180
+ startedAt: Date | null;
181
+ }
182
+
183
+ /**
184
+ * Watch event handler
185
+ */
186
+ export type WatchEventHandler = (warning: WatchWarning) => void;
187
+
188
+ // ========== Memory Types ==========
189
+
190
+ /**
191
+ * Session memory (lightweight, no persistence)
192
+ */
193
+ export interface BrainMemory {
194
+ /** Last Guard check result */
195
+ lastGuardResult: GuardViolation[] | null;
196
+ /** Last file diff */
197
+ lastDiff: string | null;
198
+ /** Current spec snapshot */
199
+ specSnapshot: RoutesManifest | null;
200
+ /** Session start time */
201
+ sessionStart: Date;
202
+ /** Last activity time */
203
+ lastActivity: Date;
204
+ }
205
+
206
+ // ========== Brain Configuration ==========
207
+
208
+ /**
209
+ * Environment detection result
210
+ */
211
+ export interface EnvironmentInfo {
212
+ /** Is running in CI environment */
213
+ isCI: boolean;
214
+ /** CI provider name (if detected) */
215
+ ciProvider?: string;
216
+ /** Is development environment */
217
+ isDevelopment: boolean;
218
+ /** Detected model availability */
219
+ modelAvailable: boolean;
220
+ }
221
+
222
+ /**
223
+ * Brain configuration
224
+ */
225
+ export interface BrainConfig {
226
+ /** Whether Brain is enabled */
227
+ enabled: boolean;
228
+ /** LLM adapter configuration */
229
+ adapter?: AdapterConfig;
230
+ /** Auto-apply patches (default: false, experimental) */
231
+ autoApply?: boolean;
232
+ /** Maximum retry attempts */
233
+ maxRetries?: number;
234
+ /** Watch configuration */
235
+ watch?: {
236
+ /** Extra commands to run on violations */
237
+ extraCommands?: string[];
238
+ /** Debounce delay in ms */
239
+ debounceMs?: number;
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Brain policy
245
+ */
246
+ export interface BrainPolicy {
247
+ /** Auto-detection mode: auto | always | never */
248
+ enabled: "auto" | "always" | "never";
249
+ /** CI environment handling */
250
+ ci: boolean;
251
+ /** Behavior when model is not available */
252
+ localNoModel: "guidance-only" | "disabled";
253
+ /** Behavior when model is available */
254
+ localWithModel: boolean;
255
+ /** Core isolation (Brain failure doesn't affect Core) */
256
+ coreIsolation: boolean;
257
+ }
258
+
259
+ /**
260
+ * Default Brain policy
261
+ */
262
+ export const DEFAULT_BRAIN_POLICY: BrainPolicy = {
263
+ enabled: "auto",
264
+ ci: false,
265
+ localNoModel: "guidance-only",
266
+ localWithModel: true,
267
+ coreIsolation: true,
268
+ };
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Contract System Tests
3
+ */
4
+
5
+ import { describe, test, expect } from "bun:test";
6
+ import { z } from "zod";
7
+ import { createContract } from "./index";
8
+ import { ContractValidator, formatValidationErrors } from "./validator";
9
+ import type { ContractSchema } from "./schema";
10
+
11
+ // Test schemas
12
+ const UserSchema = z.object({
13
+ id: z.string().uuid(),
14
+ email: z.string().email(),
15
+ name: z.string().min(2),
16
+ createdAt: z.string().datetime(),
17
+ });
18
+
19
+ const CreateUserInput = UserSchema.omit({ id: true, createdAt: true });
20
+
21
+ const UserListQuery = z.object({
22
+ page: z.coerce.number().int().min(1).default(1),
23
+ limit: z.coerce.number().int().min(1).max(100).default(10),
24
+ search: z.string().optional(),
25
+ });
26
+
27
+ describe("createContract", () => {
28
+ test("should create a contract with definition", () => {
29
+ const contract = createContract({
30
+ description: "Users API",
31
+ tags: ["users"],
32
+ request: {
33
+ GET: { query: UserListQuery },
34
+ POST: { body: CreateUserInput },
35
+ },
36
+ response: {
37
+ 200: z.object({ data: z.array(UserSchema) }),
38
+ 201: z.object({ data: UserSchema }),
39
+ 400: z.object({ error: z.string() }),
40
+ },
41
+ });
42
+
43
+ expect(contract.description).toBe("Users API");
44
+ expect(contract.tags).toEqual(["users"]);
45
+ expect(contract._validated).toBe(false);
46
+ });
47
+
48
+ test("should preserve request schemas", () => {
49
+ const contract = createContract({
50
+ request: {
51
+ GET: { query: UserListQuery },
52
+ },
53
+ response: {},
54
+ });
55
+
56
+ expect(contract.request.GET).toBeDefined();
57
+ expect(contract.request.GET!.query === UserListQuery).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe("ContractValidator", () => {
62
+ const contractSchema: ContractSchema = {
63
+ request: {
64
+ GET: { query: UserListQuery },
65
+ POST: { body: CreateUserInput },
66
+ },
67
+ response: {
68
+ 200: z.object({ data: z.array(UserSchema) }),
69
+ 201: z.object({ data: UserSchema }),
70
+ 400: z.object({ error: z.string() }),
71
+ },
72
+ };
73
+
74
+ const validator = new ContractValidator(contractSchema);
75
+
76
+ describe("validateRequest", () => {
77
+ test("should validate GET request with valid query", async () => {
78
+ const req = new Request("http://localhost/api/users?page=1&limit=10");
79
+ const result = await validator.validateRequest(req, "GET");
80
+
81
+ expect(result.success).toBe(true);
82
+ });
83
+
84
+ test("should validate GET request with default values", async () => {
85
+ const req = new Request("http://localhost/api/users");
86
+ const result = await validator.validateRequest(req, "GET");
87
+
88
+ expect(result.success).toBe(true);
89
+ });
90
+
91
+ test("should fail GET request with invalid query", async () => {
92
+ const req = new Request("http://localhost/api/users?page=-1&limit=200");
93
+ const result = await validator.validateRequest(req, "GET");
94
+
95
+ expect(result.success).toBe(false);
96
+ expect(result.errors).toBeDefined();
97
+ expect(result.errors!.length).toBeGreaterThan(0);
98
+ expect(result.errors![0].type).toBe("query");
99
+ });
100
+
101
+ test("should validate POST request with valid body", async () => {
102
+ const req = new Request("http://localhost/api/users", {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify({
106
+ email: "test@example.com",
107
+ name: "Test User",
108
+ }),
109
+ });
110
+ const result = await validator.validateRequest(req, "POST");
111
+
112
+ expect(result.success).toBe(true);
113
+ });
114
+
115
+ test("should fail POST request with invalid body", async () => {
116
+ const req = new Request("http://localhost/api/users", {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body: JSON.stringify({
120
+ email: "invalid-email",
121
+ name: "A", // Too short (min 2)
122
+ }),
123
+ });
124
+ const result = await validator.validateRequest(req, "POST");
125
+
126
+ expect(result.success).toBe(false);
127
+ expect(result.errors).toBeDefined();
128
+ expect(result.errors![0].type).toBe("body");
129
+ });
130
+
131
+ test("should fail POST request with missing required fields", async () => {
132
+ const req = new Request("http://localhost/api/users", {
133
+ method: "POST",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify({
136
+ email: "test@example.com",
137
+ // name is missing
138
+ }),
139
+ });
140
+ const result = await validator.validateRequest(req, "POST");
141
+
142
+ expect(result.success).toBe(false);
143
+ expect(result.errors).toBeDefined();
144
+ });
145
+
146
+ test("should pass through undefined methods", async () => {
147
+ const req = new Request("http://localhost/api/users", {
148
+ method: "DELETE",
149
+ });
150
+ const result = await validator.validateRequest(req, "DELETE");
151
+
152
+ expect(result.success).toBe(true);
153
+ });
154
+ });
155
+
156
+ describe("validateResponse", () => {
157
+ test("should validate 200 response with valid data", () => {
158
+ const responseBody = {
159
+ data: [
160
+ {
161
+ id: "550e8400-e29b-41d4-a716-446655440000",
162
+ email: "test@example.com",
163
+ name: "Test User",
164
+ createdAt: "2024-01-15T10:30:00Z",
165
+ },
166
+ ],
167
+ };
168
+ const result = validator.validateResponse(responseBody, 200);
169
+
170
+ expect(result.success).toBe(true);
171
+ });
172
+
173
+ test("should validate 201 response with valid user", () => {
174
+ const responseBody = {
175
+ data: {
176
+ id: "550e8400-e29b-41d4-a716-446655440000",
177
+ email: "test@example.com",
178
+ name: "Test User",
179
+ createdAt: "2024-01-15T10:30:00Z",
180
+ },
181
+ };
182
+ const result = validator.validateResponse(responseBody, 201);
183
+
184
+ expect(result.success).toBe(true);
185
+ });
186
+
187
+ test("should fail 200 response with invalid data", () => {
188
+ const responseBody = {
189
+ data: [
190
+ {
191
+ id: "not-a-uuid",
192
+ email: "invalid",
193
+ name: "T",
194
+ createdAt: "not-a-date",
195
+ },
196
+ ],
197
+ };
198
+ const result = validator.validateResponse(responseBody, 200);
199
+
200
+ expect(result.success).toBe(false);
201
+ expect(result.errors).toBeDefined();
202
+ expect(result.errors![0].type).toBe("response");
203
+ });
204
+
205
+ test("should pass through undefined status codes", () => {
206
+ const responseBody = { anything: "works" };
207
+ const result = validator.validateResponse(responseBody, 500);
208
+
209
+ expect(result.success).toBe(true);
210
+ });
211
+ });
212
+
213
+ describe("helper methods", () => {
214
+ test("getMethods should return defined methods", () => {
215
+ const methods = validator.getMethods();
216
+ expect(methods).toContain("GET");
217
+ expect(methods).toContain("POST");
218
+ expect(methods).not.toContain("DELETE");
219
+ });
220
+
221
+ test("getStatusCodes should return defined status codes", () => {
222
+ const codes = validator.getStatusCodes();
223
+ expect(codes).toContain(200);
224
+ expect(codes).toContain(201);
225
+ expect(codes).toContain(400);
226
+ expect(codes).not.toContain(500);
227
+ });
228
+
229
+ test("hasMethodSchema should check method existence", () => {
230
+ expect(validator.hasMethodSchema("GET")).toBe(true);
231
+ expect(validator.hasMethodSchema("POST")).toBe(true);
232
+ expect(validator.hasMethodSchema("DELETE")).toBe(false);
233
+ });
234
+
235
+ test("hasResponseSchema should check status code existence", () => {
236
+ expect(validator.hasResponseSchema(200)).toBe(true);
237
+ expect(validator.hasResponseSchema(400)).toBe(true);
238
+ expect(validator.hasResponseSchema(500)).toBe(false);
239
+ });
240
+ });
241
+ });
242
+
243
+ describe("formatValidationErrors", () => {
244
+ test("should format validation errors for HTTP response", () => {
245
+ const errors = [
246
+ {
247
+ type: "query" as const,
248
+ issues: [
249
+ { path: ["page"], message: "Number must be greater than 0", code: "too_small" },
250
+ ],
251
+ },
252
+ {
253
+ type: "body" as const,
254
+ issues: [
255
+ { path: ["email"], message: "Invalid email", code: "invalid_string" },
256
+ { path: ["name"], message: "String must contain at least 2 character(s)", code: "too_small" },
257
+ ],
258
+ },
259
+ ];
260
+
261
+ const formatted = formatValidationErrors(errors);
262
+
263
+ expect(formatted.error).toBe("Validation Error");
264
+ expect(formatted.details).toHaveLength(2);
265
+ expect(formatted.details[0].type).toBe("query");
266
+ expect(formatted.details[0].issues[0].path).toBe("page");
267
+ expect(formatted.details[1].type).toBe("body");
268
+ expect(formatted.details[1].issues).toHaveLength(2);
269
+ });
270
+
271
+ test("should handle root path errors", () => {
272
+ const errors = [
273
+ {
274
+ type: "body" as const,
275
+ issues: [{ path: [], message: "Invalid JSON", code: "invalid_type" }],
276
+ },
277
+ ];
278
+
279
+ const formatted = formatValidationErrors(errors);
280
+ expect(formatted.details[0].issues[0].path).toBe("(root)");
281
+ });
282
+ });
283
+
284
+ describe("Path Parameters Validation", () => {
285
+ const contractWithParams: ContractSchema = {
286
+ request: {
287
+ GET: {
288
+ params: z.object({
289
+ id: z.string().uuid(),
290
+ }),
291
+ },
292
+ PUT: {
293
+ params: z.object({
294
+ id: z.string().uuid(),
295
+ }),
296
+ body: CreateUserInput,
297
+ },
298
+ },
299
+ response: {
300
+ 200: z.object({ data: UserSchema }),
301
+ },
302
+ };
303
+
304
+ const validator = new ContractValidator(contractWithParams);
305
+
306
+ test("should validate path parameters", async () => {
307
+ const req = new Request("http://localhost/api/users/550e8400-e29b-41d4-a716-446655440000");
308
+ const result = await validator.validateRequest(req, "GET", {
309
+ id: "550e8400-e29b-41d4-a716-446655440000",
310
+ });
311
+
312
+ expect(result.success).toBe(true);
313
+ });
314
+
315
+ test("should fail invalid path parameters", async () => {
316
+ const req = new Request("http://localhost/api/users/not-a-uuid");
317
+ const result = await validator.validateRequest(req, "GET", {
318
+ id: "not-a-uuid",
319
+ });
320
+
321
+ expect(result.success).toBe(false);
322
+ expect(result.errors![0].type).toBe("params");
323
+ });
324
+
325
+ test("should validate both params and body", async () => {
326
+ const req = new Request("http://localhost/api/users/550e8400-e29b-41d4-a716-446655440000", {
327
+ method: "PUT",
328
+ headers: { "Content-Type": "application/json" },
329
+ body: JSON.stringify({
330
+ email: "updated@example.com",
331
+ name: "Updated User",
332
+ }),
333
+ });
334
+ const result = await validator.validateRequest(req, "PUT", {
335
+ id: "550e8400-e29b-41d4-a716-446655440000",
336
+ });
337
+
338
+ expect(result.success).toBe(true);
339
+ });
340
+ });
341
+
342
+ describe("Headers Validation", () => {
343
+ const contractWithHeaders: ContractSchema = {
344
+ request: {
345
+ GET: {
346
+ headers: z.object({
347
+ authorization: z.string().startsWith("Bearer "),
348
+ "x-api-key": z.string().min(32),
349
+ }),
350
+ },
351
+ },
352
+ response: {},
353
+ };
354
+
355
+ const validator = new ContractValidator(contractWithHeaders);
356
+
357
+ test("should validate headers", async () => {
358
+ const req = new Request("http://localhost/api/protected", {
359
+ headers: {
360
+ Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
361
+ "X-API-Key": "12345678901234567890123456789012",
362
+ },
363
+ });
364
+ const result = await validator.validateRequest(req, "GET");
365
+
366
+ expect(result.success).toBe(true);
367
+ });
368
+
369
+ test("should fail invalid headers", async () => {
370
+ const req = new Request("http://localhost/api/protected", {
371
+ headers: {
372
+ Authorization: "Invalid token",
373
+ "X-API-Key": "short",
374
+ },
375
+ });
376
+ const result = await validator.validateRequest(req, "GET");
377
+
378
+ expect(result.success).toBe(false);
379
+ expect(result.errors![0].type).toBe("headers");
380
+ });
381
+ });