@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.
- package/package.json +9 -1
- package/src/brain/adapters/base.ts +120 -0
- package/src/brain/adapters/index.ts +8 -0
- package/src/brain/adapters/ollama.ts +249 -0
- package/src/brain/brain.ts +324 -0
- package/src/brain/doctor/analyzer.ts +366 -0
- package/src/brain/doctor/index.ts +40 -0
- package/src/brain/doctor/patcher.ts +349 -0
- package/src/brain/doctor/reporter.ts +336 -0
- package/src/brain/index.ts +45 -0
- package/src/brain/memory.ts +154 -0
- package/src/brain/permissions.ts +270 -0
- package/src/brain/types.ts +268 -0
- package/src/contract/contract.test.ts +381 -0
- package/src/contract/integration.test.ts +394 -0
- package/src/contract/validator.ts +113 -8
- package/src/generator/contract-glue.test.ts +211 -0
- package/src/guard/check.ts +51 -1
- package/src/guard/contract-guard.test.ts +303 -0
- package/src/guard/rules.ts +37 -0
- package/src/index.ts +2 -0
- package/src/openapi/openapi.test.ts +277 -0
- package/src/slot/validator.test.ts +203 -0
- package/src/slot/validator.ts +236 -17
- package/src/watcher/index.ts +44 -0
- package/src/watcher/reporter.ts +232 -0
- package/src/watcher/rules.ts +248 -0
- package/src/watcher/watcher.ts +330 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract Glue Generator Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
generateContractTypeGlue,
|
|
8
|
+
generateContractTemplate,
|
|
9
|
+
generateContractTypesIndex,
|
|
10
|
+
} from "./contract-glue";
|
|
11
|
+
import type { RouteSpec } from "../spec/schema";
|
|
12
|
+
|
|
13
|
+
describe("generateContractTypeGlue", () => {
|
|
14
|
+
test("should generate type glue for route with contract", () => {
|
|
15
|
+
const route: RouteSpec = {
|
|
16
|
+
id: "users",
|
|
17
|
+
pattern: "/api/users",
|
|
18
|
+
kind: "api",
|
|
19
|
+
module: "generated/routes/api/users.ts",
|
|
20
|
+
contractModule: "spec/contracts/users.contract.ts",
|
|
21
|
+
slotModule: "spec/slots/users.slot.ts",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const result = generateContractTypeGlue(route);
|
|
25
|
+
|
|
26
|
+
expect(result).toContain("// Generated by Mandu");
|
|
27
|
+
expect(result).toContain("import type { InferContract");
|
|
28
|
+
expect(result).toContain("export type UsersContract");
|
|
29
|
+
expect(result).toContain("export type UsersGetQuery");
|
|
30
|
+
expect(result).toContain("export type UsersPostBody");
|
|
31
|
+
expect(result).toContain("export type UsersResponse200");
|
|
32
|
+
expect(result).toContain("export { contract }");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("should return empty string for route without contract", () => {
|
|
36
|
+
const route: RouteSpec = {
|
|
37
|
+
id: "users",
|
|
38
|
+
pattern: "/api/users",
|
|
39
|
+
kind: "api",
|
|
40
|
+
module: "generated/routes/api/users.ts",
|
|
41
|
+
slotModule: "spec/slots/users.slot.ts",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const result = generateContractTypeGlue(route);
|
|
45
|
+
|
|
46
|
+
expect(result).toBe("");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("should convert kebab-case to PascalCase", () => {
|
|
50
|
+
const route: RouteSpec = {
|
|
51
|
+
id: "user-profiles",
|
|
52
|
+
pattern: "/api/user-profiles",
|
|
53
|
+
kind: "api",
|
|
54
|
+
module: "generated/routes/api/user-profiles.ts",
|
|
55
|
+
contractModule: "spec/contracts/user-profiles.contract.ts",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const result = generateContractTypeGlue(route);
|
|
59
|
+
|
|
60
|
+
expect(result).toContain("export type UserProfilesContract");
|
|
61
|
+
expect(result).toContain("export type UserProfilesGetQuery");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("should convert snake_case to PascalCase", () => {
|
|
65
|
+
const route: RouteSpec = {
|
|
66
|
+
id: "user_settings",
|
|
67
|
+
pattern: "/api/user_settings",
|
|
68
|
+
kind: "api",
|
|
69
|
+
module: "generated/routes/api/user_settings.ts",
|
|
70
|
+
contractModule: "spec/contracts/user_settings.contract.ts",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const result = generateContractTypeGlue(route);
|
|
74
|
+
|
|
75
|
+
expect(result).toContain("export type UserSettingsContract");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("generateContractTemplate", () => {
|
|
80
|
+
test("should generate template with GET method", () => {
|
|
81
|
+
const route: RouteSpec = {
|
|
82
|
+
id: "users",
|
|
83
|
+
pattern: "/api/users",
|
|
84
|
+
kind: "api",
|
|
85
|
+
module: "generated/routes/api/users.ts",
|
|
86
|
+
methods: ["GET"],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const result = generateContractTemplate(route);
|
|
90
|
+
|
|
91
|
+
expect(result).toContain("// π Mandu Contract - users");
|
|
92
|
+
expect(result).toContain("// Pattern: /api/users");
|
|
93
|
+
expect(result).toContain('import { z } from "zod"');
|
|
94
|
+
expect(result).toContain("Mandu.contract({");
|
|
95
|
+
expect(result).toContain("GET: {");
|
|
96
|
+
expect(result).toContain("query: z.object({");
|
|
97
|
+
expect(result).not.toContain("POST:");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("should generate template with GET and POST methods", () => {
|
|
101
|
+
const route: RouteSpec = {
|
|
102
|
+
id: "users",
|
|
103
|
+
pattern: "/api/users",
|
|
104
|
+
kind: "api",
|
|
105
|
+
module: "generated/routes/api/users.ts",
|
|
106
|
+
methods: ["GET", "POST"],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const result = generateContractTemplate(route);
|
|
110
|
+
|
|
111
|
+
expect(result).toContain("GET: {");
|
|
112
|
+
expect(result).toContain("POST: {");
|
|
113
|
+
expect(result).toContain("body: z.object({");
|
|
114
|
+
expect(result).toContain("201: z.object({"); // 201 for POST
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("should generate template with all CRUD methods", () => {
|
|
118
|
+
const route: RouteSpec = {
|
|
119
|
+
id: "users",
|
|
120
|
+
pattern: "/api/users/:id",
|
|
121
|
+
kind: "api",
|
|
122
|
+
module: "generated/routes/api/users.ts",
|
|
123
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const result = generateContractTemplate(route);
|
|
127
|
+
|
|
128
|
+
expect(result).toContain("GET: {");
|
|
129
|
+
expect(result).toContain("POST: {");
|
|
130
|
+
expect(result).toContain("PUT: {");
|
|
131
|
+
expect(result).toContain("PATCH: {");
|
|
132
|
+
expect(result).toContain("DELETE: {");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("should include helpful comments", () => {
|
|
136
|
+
const route: RouteSpec = {
|
|
137
|
+
id: "users",
|
|
138
|
+
pattern: "/api/users",
|
|
139
|
+
kind: "api",
|
|
140
|
+
module: "generated/routes/api/users.ts",
|
|
141
|
+
methods: ["GET"],
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const result = generateContractTemplate(route);
|
|
145
|
+
|
|
146
|
+
expect(result).toContain("// TODO:");
|
|
147
|
+
expect(result).toContain("π‘ Contract μ¬μ©λ²:");
|
|
148
|
+
expect(result).toContain("mandu generate");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("should set correct description and tags", () => {
|
|
152
|
+
const route: RouteSpec = {
|
|
153
|
+
id: "user-profiles",
|
|
154
|
+
pattern: "/api/user-profiles",
|
|
155
|
+
kind: "api",
|
|
156
|
+
module: "generated/routes/api/user-profiles.ts",
|
|
157
|
+
methods: ["GET"],
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const result = generateContractTemplate(route);
|
|
161
|
+
|
|
162
|
+
expect(result).toContain('description: "UserProfiles API"');
|
|
163
|
+
expect(result).toContain('tags: ["user-profiles"]');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("should include error response schemas", () => {
|
|
167
|
+
const route: RouteSpec = {
|
|
168
|
+
id: "users",
|
|
169
|
+
pattern: "/api/users",
|
|
170
|
+
kind: "api",
|
|
171
|
+
module: "generated/routes/api/users.ts",
|
|
172
|
+
methods: ["GET"],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const result = generateContractTemplate(route);
|
|
176
|
+
|
|
177
|
+
expect(result).toContain("400: z.object({");
|
|
178
|
+
expect(result).toContain("error: z.string()");
|
|
179
|
+
expect(result).toContain("404: z.object({");
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("generateContractTypesIndex", () => {
|
|
184
|
+
test("should generate index file with all route exports", () => {
|
|
185
|
+
const routeIds = ["users", "posts", "comments"];
|
|
186
|
+
|
|
187
|
+
const result = generateContractTypesIndex(routeIds);
|
|
188
|
+
|
|
189
|
+
expect(result).toContain("// Generated by Mandu");
|
|
190
|
+
expect(result).toContain('export * from "./users.types";');
|
|
191
|
+
expect(result).toContain('export * from "./posts.types";');
|
|
192
|
+
expect(result).toContain('export * from "./comments.types";');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("should handle empty route list", () => {
|
|
196
|
+
const routeIds: string[] = [];
|
|
197
|
+
|
|
198
|
+
const result = generateContractTypesIndex(routeIds);
|
|
199
|
+
|
|
200
|
+
expect(result).toContain("// Generated by Mandu");
|
|
201
|
+
expect(result).not.toContain("export *");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("should handle single route", () => {
|
|
205
|
+
const routeIds = ["users"];
|
|
206
|
+
|
|
207
|
+
const result = generateContractTypesIndex(routeIds);
|
|
208
|
+
|
|
209
|
+
expect(result).toContain('export * from "./users.types";');
|
|
210
|
+
});
|
|
211
|
+
});
|
package/src/guard/check.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { GUARD_RULES, FORBIDDEN_IMPORTS, type GuardViolation } from "./rules";
|
|
2
2
|
import { verifyLock, computeHash } from "../spec/lock";
|
|
3
3
|
import { runContractGuardCheck } from "./contract-guard";
|
|
4
|
+
import { validateSlotContent } from "../slot/validator";
|
|
4
5
|
import type { RoutesManifest } from "../spec/schema";
|
|
5
6
|
import type { GeneratedMap } from "../generator/generate";
|
|
6
7
|
import path from "path";
|
|
@@ -147,6 +148,51 @@ export async function checkSlotFileExists(
|
|
|
147
148
|
return violations;
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
// Rule 6: Slot content validation (μ κ·)
|
|
152
|
+
export async function checkSlotContentValidation(
|
|
153
|
+
manifest: RoutesManifest,
|
|
154
|
+
rootDir: string
|
|
155
|
+
): Promise<GuardViolation[]> {
|
|
156
|
+
const violations: GuardViolation[] = [];
|
|
157
|
+
|
|
158
|
+
for (const route of manifest.routes) {
|
|
159
|
+
if (!route.slotModule) continue;
|
|
160
|
+
|
|
161
|
+
const slotPath = path.join(rootDir, route.slotModule);
|
|
162
|
+
const content = await readFileContent(slotPath);
|
|
163
|
+
|
|
164
|
+
if (!content) continue; // File doesn't exist, handled by checkSlotFileExists
|
|
165
|
+
|
|
166
|
+
const validationResult = validateSlotContent(content);
|
|
167
|
+
|
|
168
|
+
// Convert slot validation issues to guard violations
|
|
169
|
+
for (const issue of validationResult.issues) {
|
|
170
|
+
if (issue.severity === "error") {
|
|
171
|
+
// Map slot issue codes to guard rule IDs
|
|
172
|
+
let ruleId = "SLOT_VALIDATION_ERROR";
|
|
173
|
+
if (issue.code === "MISSING_DEFAULT_EXPORT") {
|
|
174
|
+
ruleId = GUARD_RULES.SLOT_MISSING_DEFAULT_EXPORT?.id ?? "SLOT_MISSING_DEFAULT_EXPORT";
|
|
175
|
+
} else if (issue.code === "NO_RESPONSE_PATTERN" || issue.code === "INVALID_HANDLER_RETURN") {
|
|
176
|
+
ruleId = GUARD_RULES.SLOT_INVALID_RETURN?.id ?? "SLOT_INVALID_RETURN";
|
|
177
|
+
} else if (issue.code === "MISSING_FILLING_PATTERN") {
|
|
178
|
+
ruleId = GUARD_RULES.SLOT_MISSING_FILLING_PATTERN?.id ?? "SLOT_MISSING_FILLING_PATTERN";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
violations.push({
|
|
182
|
+
ruleId,
|
|
183
|
+
file: route.slotModule,
|
|
184
|
+
message: `[${route.id}] ${issue.message}`,
|
|
185
|
+
suggestion: issue.suggestion,
|
|
186
|
+
line: issue.line,
|
|
187
|
+
severity: issue.severity,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return violations;
|
|
194
|
+
}
|
|
195
|
+
|
|
150
196
|
// Rule 4: Forbidden imports in generated files
|
|
151
197
|
export async function checkForbiddenImportsInGenerated(
|
|
152
198
|
rootDir: string,
|
|
@@ -249,7 +295,11 @@ export async function runGuardCheck(
|
|
|
249
295
|
const slotViolations = await checkSlotFileExists(manifest, rootDir);
|
|
250
296
|
violations.push(...slotViolations);
|
|
251
297
|
|
|
252
|
-
// Rule 6
|
|
298
|
+
// Rule 6: Slot content validation (μ κ· - κ°νλ κ²μ¦)
|
|
299
|
+
const slotContentViolations = await checkSlotContentValidation(manifest, rootDir);
|
|
300
|
+
violations.push(...slotContentViolations);
|
|
301
|
+
|
|
302
|
+
// Rule 7-10: Contract-related checks
|
|
253
303
|
const contractViolations = await runContractGuardCheck(manifest, rootDir);
|
|
254
304
|
violations.push(...contractViolations);
|
|
255
305
|
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract Guard Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
6
|
+
import { mkdir, rm, writeFile } from "fs/promises";
|
|
7
|
+
import path from "path";
|
|
8
|
+
import {
|
|
9
|
+
checkMissingContract,
|
|
10
|
+
checkContractFileExists,
|
|
11
|
+
checkContractSlotConsistency,
|
|
12
|
+
runContractGuardCheck,
|
|
13
|
+
} from "./contract-guard";
|
|
14
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
15
|
+
|
|
16
|
+
const TEST_DIR = path.join(process.cwd(), ".test-guard");
|
|
17
|
+
|
|
18
|
+
describe("Contract Guard", () => {
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
await mkdir(TEST_DIR, { recursive: true });
|
|
21
|
+
await mkdir(path.join(TEST_DIR, "spec/contracts"), { recursive: true });
|
|
22
|
+
await mkdir(path.join(TEST_DIR, "spec/slots"), { recursive: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("checkMissingContract", () => {
|
|
30
|
+
test("should detect API routes without contracts", async () => {
|
|
31
|
+
const manifest: RoutesManifest = {
|
|
32
|
+
version: 1,
|
|
33
|
+
routes: [
|
|
34
|
+
{
|
|
35
|
+
id: "users",
|
|
36
|
+
pattern: "/api/users",
|
|
37
|
+
kind: "api",
|
|
38
|
+
module: "generated/routes/api/users.ts",
|
|
39
|
+
slotModule: "spec/slots/users.slot.ts",
|
|
40
|
+
// No contractModule
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const violations = await checkMissingContract(manifest, TEST_DIR);
|
|
46
|
+
|
|
47
|
+
expect(violations.length).toBe(1);
|
|
48
|
+
expect(violations[0].ruleId).toBe("CONTRACT_MISSING");
|
|
49
|
+
expect(violations[0].routeId).toBe("users");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("should pass API routes with contracts", async () => {
|
|
53
|
+
const manifest: RoutesManifest = {
|
|
54
|
+
version: 1,
|
|
55
|
+
routes: [
|
|
56
|
+
{
|
|
57
|
+
id: "users",
|
|
58
|
+
pattern: "/api/users",
|
|
59
|
+
kind: "api",
|
|
60
|
+
module: "generated/routes/api/users.ts",
|
|
61
|
+
slotModule: "spec/slots/users.slot.ts",
|
|
62
|
+
contractModule: "spec/contracts/users.contract.ts",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const violations = await checkMissingContract(manifest, TEST_DIR);
|
|
68
|
+
|
|
69
|
+
expect(violations.length).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("should skip non-API routes", async () => {
|
|
73
|
+
const manifest: RoutesManifest = {
|
|
74
|
+
version: 1,
|
|
75
|
+
routes: [
|
|
76
|
+
{
|
|
77
|
+
id: "home",
|
|
78
|
+
pattern: "/",
|
|
79
|
+
kind: "page",
|
|
80
|
+
module: "generated/routes/home.ts",
|
|
81
|
+
slotModule: "spec/slots/home.slot.ts",
|
|
82
|
+
// No contractModule - but that's fine for pages
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const violations = await checkMissingContract(manifest, TEST_DIR);
|
|
88
|
+
|
|
89
|
+
expect(violations.length).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("checkContractFileExists", () => {
|
|
94
|
+
test("should detect missing contract files", async () => {
|
|
95
|
+
const manifest: RoutesManifest = {
|
|
96
|
+
version: 1,
|
|
97
|
+
routes: [
|
|
98
|
+
{
|
|
99
|
+
id: "users",
|
|
100
|
+
pattern: "/api/users",
|
|
101
|
+
kind: "api",
|
|
102
|
+
module: "generated/routes/api/users.ts",
|
|
103
|
+
contractModule: "spec/contracts/users.contract.ts",
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const violations = await checkContractFileExists(manifest, TEST_DIR);
|
|
109
|
+
|
|
110
|
+
expect(violations.length).toBe(1);
|
|
111
|
+
expect(violations[0].ruleId).toBe("CONTRACT_NOT_FOUND");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("should pass when contract file exists", async () => {
|
|
115
|
+
// Create contract file
|
|
116
|
+
await writeFile(
|
|
117
|
+
path.join(TEST_DIR, "spec/contracts/users.contract.ts"),
|
|
118
|
+
`export default { request: {}, response: {} }`
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const manifest: RoutesManifest = {
|
|
122
|
+
version: 1,
|
|
123
|
+
routes: [
|
|
124
|
+
{
|
|
125
|
+
id: "users",
|
|
126
|
+
pattern: "/api/users",
|
|
127
|
+
kind: "api",
|
|
128
|
+
module: "generated/routes/api/users.ts",
|
|
129
|
+
contractModule: "spec/contracts/users.contract.ts",
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const violations = await checkContractFileExists(manifest, TEST_DIR);
|
|
135
|
+
|
|
136
|
+
expect(violations.length).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("checkContractSlotConsistency", () => {
|
|
141
|
+
test("should detect methods in contract but not in slot", async () => {
|
|
142
|
+
// Create contract with GET and POST
|
|
143
|
+
await writeFile(
|
|
144
|
+
path.join(TEST_DIR, "spec/contracts/users.contract.ts"),
|
|
145
|
+
`
|
|
146
|
+
export default {
|
|
147
|
+
request: {
|
|
148
|
+
GET: { query: {} },
|
|
149
|
+
POST: { body: {} },
|
|
150
|
+
DELETE: {},
|
|
151
|
+
},
|
|
152
|
+
response: {},
|
|
153
|
+
};
|
|
154
|
+
`
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Create slot with only GET
|
|
158
|
+
await writeFile(
|
|
159
|
+
path.join(TEST_DIR, "spec/slots/users.slot.ts"),
|
|
160
|
+
`
|
|
161
|
+
export default Mandu.filling()
|
|
162
|
+
.get((ctx) => ctx.ok({ data: [] }));
|
|
163
|
+
`
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const manifest: RoutesManifest = {
|
|
167
|
+
version: 1,
|
|
168
|
+
routes: [
|
|
169
|
+
{
|
|
170
|
+
id: "users",
|
|
171
|
+
pattern: "/api/users",
|
|
172
|
+
kind: "api",
|
|
173
|
+
module: "generated/routes/api/users.ts",
|
|
174
|
+
contractModule: "spec/contracts/users.contract.ts",
|
|
175
|
+
slotModule: "spec/slots/users.slot.ts",
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const violations = await checkContractSlotConsistency(manifest, TEST_DIR);
|
|
181
|
+
|
|
182
|
+
expect(violations.length).toBe(1);
|
|
183
|
+
expect(violations[0].ruleId).toBe("CONTRACT_METHOD_NOT_IMPLEMENTED");
|
|
184
|
+
expect(violations[0].missingMethods).toContain("POST");
|
|
185
|
+
expect(violations[0].missingMethods).toContain("DELETE");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("should detect methods in slot but not in contract", async () => {
|
|
189
|
+
// Create contract with only GET
|
|
190
|
+
await writeFile(
|
|
191
|
+
path.join(TEST_DIR, "spec/contracts/users.contract.ts"),
|
|
192
|
+
`
|
|
193
|
+
export default {
|
|
194
|
+
request: {
|
|
195
|
+
GET: { query: {} },
|
|
196
|
+
},
|
|
197
|
+
response: {},
|
|
198
|
+
};
|
|
199
|
+
`
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// Create slot with GET, POST, and DELETE
|
|
203
|
+
await writeFile(
|
|
204
|
+
path.join(TEST_DIR, "spec/slots/users.slot.ts"),
|
|
205
|
+
`
|
|
206
|
+
export default Mandu.filling()
|
|
207
|
+
.get((ctx) => ctx.ok({ data: [] }))
|
|
208
|
+
.post((ctx) => ctx.created({}))
|
|
209
|
+
.delete((ctx) => ctx.noContent());
|
|
210
|
+
`
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const manifest: RoutesManifest = {
|
|
214
|
+
version: 1,
|
|
215
|
+
routes: [
|
|
216
|
+
{
|
|
217
|
+
id: "users",
|
|
218
|
+
pattern: "/api/users",
|
|
219
|
+
kind: "api",
|
|
220
|
+
module: "generated/routes/api/users.ts",
|
|
221
|
+
contractModule: "spec/contracts/users.contract.ts",
|
|
222
|
+
slotModule: "spec/slots/users.slot.ts",
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const violations = await checkContractSlotConsistency(manifest, TEST_DIR);
|
|
228
|
+
|
|
229
|
+
expect(violations.length).toBe(1);
|
|
230
|
+
expect(violations[0].ruleId).toBe("CONTRACT_METHOD_UNDOCUMENTED");
|
|
231
|
+
expect(violations[0].undocumentedMethods).toContain("POST");
|
|
232
|
+
expect(violations[0].undocumentedMethods).toContain("DELETE");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("should pass when contract and slot are in sync", async () => {
|
|
236
|
+
// Create contract with GET and POST
|
|
237
|
+
await writeFile(
|
|
238
|
+
path.join(TEST_DIR, "spec/contracts/users.contract.ts"),
|
|
239
|
+
`
|
|
240
|
+
export default {
|
|
241
|
+
request: {
|
|
242
|
+
GET: { query: {} },
|
|
243
|
+
POST: { body: {} },
|
|
244
|
+
},
|
|
245
|
+
response: {},
|
|
246
|
+
};
|
|
247
|
+
`
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Create slot with GET and POST
|
|
251
|
+
await writeFile(
|
|
252
|
+
path.join(TEST_DIR, "spec/slots/users.slot.ts"),
|
|
253
|
+
`
|
|
254
|
+
export default Mandu.filling()
|
|
255
|
+
.get((ctx) => ctx.ok({ data: [] }))
|
|
256
|
+
.post((ctx) => ctx.created({}));
|
|
257
|
+
`
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const manifest: RoutesManifest = {
|
|
261
|
+
version: 1,
|
|
262
|
+
routes: [
|
|
263
|
+
{
|
|
264
|
+
id: "users",
|
|
265
|
+
pattern: "/api/users",
|
|
266
|
+
kind: "api",
|
|
267
|
+
module: "generated/routes/api/users.ts",
|
|
268
|
+
contractModule: "spec/contracts/users.contract.ts",
|
|
269
|
+
slotModule: "spec/slots/users.slot.ts",
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const violations = await checkContractSlotConsistency(manifest, TEST_DIR);
|
|
275
|
+
|
|
276
|
+
expect(violations.length).toBe(0);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("runContractGuardCheck", () => {
|
|
281
|
+
test("should run all checks", async () => {
|
|
282
|
+
// Missing contract file
|
|
283
|
+
const manifest: RoutesManifest = {
|
|
284
|
+
version: 1,
|
|
285
|
+
routes: [
|
|
286
|
+
{
|
|
287
|
+
id: "users",
|
|
288
|
+
pattern: "/api/users",
|
|
289
|
+
kind: "api",
|
|
290
|
+
module: "generated/routes/api/users.ts",
|
|
291
|
+
contractModule: "spec/contracts/users.contract.ts",
|
|
292
|
+
slotModule: "spec/slots/users.slot.ts",
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const violations = await runContractGuardCheck(manifest, TEST_DIR);
|
|
298
|
+
|
|
299
|
+
// Should find CONTRACT_NOT_FOUND
|
|
300
|
+
expect(violations.some((v) => v.ruleId === "CONTRACT_NOT_FOUND")).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
});
|
package/src/guard/rules.ts
CHANGED
|
@@ -3,12 +3,15 @@ export interface GuardViolation {
|
|
|
3
3
|
file: string;
|
|
4
4
|
message: string;
|
|
5
5
|
suggestion: string;
|
|
6
|
+
line?: number;
|
|
7
|
+
severity?: "error" | "warning";
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
export interface GuardRule {
|
|
9
11
|
id: string;
|
|
10
12
|
name: string;
|
|
11
13
|
description: string;
|
|
14
|
+
severity: "error" | "warning";
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export const GUARD_RULES: Record<string, GuardRule> = {
|
|
@@ -16,47 +19,81 @@ export const GUARD_RULES: Record<string, GuardRule> = {
|
|
|
16
19
|
id: "SPEC_HASH_MISMATCH",
|
|
17
20
|
name: "Spec Hash Mismatch",
|
|
18
21
|
description: "spec.lock.jsonμ ν΄μμ νμ¬ specμ΄ μΌμΉνμ§ μμ΅λλ€",
|
|
22
|
+
severity: "error",
|
|
19
23
|
},
|
|
20
24
|
GENERATED_MANUAL_EDIT: {
|
|
21
25
|
id: "GENERATED_MANUAL_EDIT",
|
|
22
26
|
name: "Generated File Manual Edit",
|
|
23
27
|
description: "generated νμΌμ΄ μλμΌλ‘ λ³κ²½λμμ΅λλ€",
|
|
28
|
+
severity: "error",
|
|
24
29
|
},
|
|
25
30
|
INVALID_GENERATED_IMPORT: {
|
|
26
31
|
id: "INVALID_GENERATED_IMPORT",
|
|
27
32
|
name: "Invalid Generated Import",
|
|
28
33
|
description: "non-generated νμΌμμ generated νμΌμ μ§μ import νμ΅λλ€",
|
|
34
|
+
severity: "error",
|
|
29
35
|
},
|
|
30
36
|
FORBIDDEN_IMPORT_IN_GENERATED: {
|
|
31
37
|
id: "FORBIDDEN_IMPORT_IN_GENERATED",
|
|
32
38
|
name: "Forbidden Import in Generated",
|
|
33
39
|
description: "generated νμΌμμ κΈμ§λ λͺ¨λμ import νμ΅λλ€",
|
|
40
|
+
severity: "error",
|
|
34
41
|
},
|
|
35
42
|
SLOT_NOT_FOUND: {
|
|
36
43
|
id: "SLOT_NOT_FOUND",
|
|
37
44
|
name: "Slot File Not Found",
|
|
38
45
|
description: "specμ λͺ
μλ slotModule νμΌμ μ°Ύμ μ μμ΅λλ€",
|
|
46
|
+
severity: "error",
|
|
47
|
+
},
|
|
48
|
+
// Slot validation rules (μ κ·)
|
|
49
|
+
SLOT_MISSING_DEFAULT_EXPORT: {
|
|
50
|
+
id: "SLOT_MISSING_DEFAULT_EXPORT",
|
|
51
|
+
name: "Slot Missing Default Export",
|
|
52
|
+
description: "Slot νμΌμ export defaultκ° μμ΅λλ€",
|
|
53
|
+
severity: "error",
|
|
54
|
+
},
|
|
55
|
+
SLOT_INVALID_RETURN: {
|
|
56
|
+
id: "SLOT_INVALID_RETURN",
|
|
57
|
+
name: "Slot Invalid Handler Return",
|
|
58
|
+
description: "Slot νΈλ€λ¬κ° μ¬λ°λ₯Έ Responseλ₯Ό λ°ννμ§ μμ΅λλ€",
|
|
59
|
+
severity: "error",
|
|
60
|
+
},
|
|
61
|
+
SLOT_NO_RESPONSE_PATTERN: {
|
|
62
|
+
id: "SLOT_NO_RESPONSE_PATTERN",
|
|
63
|
+
name: "Slot No Response Pattern",
|
|
64
|
+
description: "Slot νΈλ€λ¬μ ctx.ok(), ctx.json() λ±μ μλ΅ ν¨ν΄μ΄ μμ΅λλ€",
|
|
65
|
+
severity: "error",
|
|
66
|
+
},
|
|
67
|
+
SLOT_MISSING_FILLING_PATTERN: {
|
|
68
|
+
id: "SLOT_MISSING_FILLING_PATTERN",
|
|
69
|
+
name: "Slot Missing Filling Pattern",
|
|
70
|
+
description: "Slot νμΌμ Mandu.filling() ν¨ν΄μ΄ μμ΅λλ€",
|
|
71
|
+
severity: "error",
|
|
39
72
|
},
|
|
40
73
|
// Contract-related rules
|
|
41
74
|
CONTRACT_MISSING: {
|
|
42
75
|
id: "CONTRACT_MISSING",
|
|
43
76
|
name: "Contract Missing",
|
|
44
77
|
description: "API λΌμ°νΈμ contractκ° μ μλμ§ μμμ΅λλ€",
|
|
78
|
+
severity: "warning",
|
|
45
79
|
},
|
|
46
80
|
CONTRACT_NOT_FOUND: {
|
|
47
81
|
id: "CONTRACT_NOT_FOUND",
|
|
48
82
|
name: "Contract File Not Found",
|
|
49
83
|
description: "specμ λͺ
μλ contractModule νμΌμ μ°Ύμ μ μμ΅λλ€",
|
|
84
|
+
severity: "error",
|
|
50
85
|
},
|
|
51
86
|
CONTRACT_METHOD_NOT_IMPLEMENTED: {
|
|
52
87
|
id: "CONTRACT_METHOD_NOT_IMPLEMENTED",
|
|
53
88
|
name: "Contract Method Not Implemented",
|
|
54
89
|
description: "Contractμ μ μλ λ©μλκ° Slotμ ꡬνλμ§ μμμ΅λλ€",
|
|
90
|
+
severity: "error",
|
|
55
91
|
},
|
|
56
92
|
CONTRACT_METHOD_UNDOCUMENTED: {
|
|
57
93
|
id: "CONTRACT_METHOD_UNDOCUMENTED",
|
|
58
94
|
name: "Contract Method Undocumented",
|
|
59
95
|
description: "Slotμ ꡬνλ λ©μλκ° Contractμ λ¬Έμνλμ§ μμμ΅λλ€",
|
|
96
|
+
severity: "warning",
|
|
60
97
|
},
|
|
61
98
|
};
|
|
62
99
|
|