ai-spec-dev 0.30.1 → 0.33.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/.claude/settings.local.json +5 -1
- package/README.md +29 -1
- package/RELEASE_LOG.md +188 -0
- package/cli/commands/config.ts +93 -0
- package/cli/commands/export.ts +66 -0
- package/cli/commands/init.ts +153 -0
- package/cli/commands/learn.ts +30 -0
- package/cli/commands/logs.ts +106 -0
- package/cli/commands/model.ts +156 -0
- package/cli/commands/restore.ts +22 -0
- package/cli/commands/review.ts +63 -0
- package/cli/commands/trend.ts +36 -0
- package/cli/commands/update.ts +178 -0
- package/cli/commands/workspace.ts +219 -0
- package/cli/index.ts +301 -1
- package/cli/utils.ts +83 -0
- package/core/dsl-feedback.ts +255 -0
- package/core/prompt-hasher.ts +42 -0
- package/core/run-logger.ts +21 -0
- package/core/run-trend.ts +241 -0
- package/core/self-evaluator.ts +276 -0
- package/dist/cli/index.js +1089 -445
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +1089 -445
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -3
- package/purpose.md +189 -2
- package/tests/dsl-extractor.test.ts +264 -0
- package/tests/dsl-feedback.test.ts +266 -0
- package/tests/dsl-validator.test.ts +283 -0
- package/tests/error-feedback.test.ts +292 -0
- package/tests/provider-utils.test.ts +173 -0
- package/tests/run-trend.test.ts +186 -0
- package/tests/self-evaluator.test.ts +339 -0
- package/tests/spec-assessor.test.ts +142 -0
- package/tests/task-generator.test.ts +230 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
assessDslRichness,
|
|
4
|
+
extractStructuralFindings,
|
|
5
|
+
buildDslGapRefinementPrompt,
|
|
6
|
+
buildStructuralAmendmentPrompt,
|
|
7
|
+
} from "../core/dsl-feedback";
|
|
8
|
+
import type { SpecDSL } from "../core/dsl-types";
|
|
9
|
+
|
|
10
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function makeEndpoint(overrides: Partial<SpecDSL["endpoints"][0]> = {}): SpecDSL["endpoints"][0] {
|
|
13
|
+
return {
|
|
14
|
+
id: "EP-001",
|
|
15
|
+
method: "GET",
|
|
16
|
+
path: "/api/items",
|
|
17
|
+
description: "Returns a paginated list of items with filtering support",
|
|
18
|
+
auth: true,
|
|
19
|
+
successStatus: 200,
|
|
20
|
+
successDescription: "List of items returned",
|
|
21
|
+
errors: [{ status: 401, code: "UNAUTHORIZED", description: "Missing auth token" }],
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeModel(overrides: Partial<SpecDSL["models"][0]> = {}): SpecDSL["models"][0] {
|
|
27
|
+
return {
|
|
28
|
+
name: "Item",
|
|
29
|
+
description: "An inventory item",
|
|
30
|
+
fields: [
|
|
31
|
+
{ name: "id", type: "String", required: true },
|
|
32
|
+
{ name: "name", type: "String", required: true },
|
|
33
|
+
{ name: "price", type: "Float", required: true },
|
|
34
|
+
],
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeDsl(overrides: Partial<SpecDSL> = {}): SpecDSL {
|
|
40
|
+
return {
|
|
41
|
+
version: "1.0",
|
|
42
|
+
feature: { id: "items", title: "Items", description: "Item management" },
|
|
43
|
+
models: [makeModel()],
|
|
44
|
+
endpoints: [makeEndpoint()],
|
|
45
|
+
behaviors: [],
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── assessDslRichness ────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe("assessDslRichness", () => {
|
|
53
|
+
it("returns no_models_no_endpoints when DSL is completely empty", () => {
|
|
54
|
+
const dsl = makeDsl({ endpoints: [], models: [] });
|
|
55
|
+
const gaps = assessDslRichness(dsl);
|
|
56
|
+
expect(gaps).toHaveLength(1);
|
|
57
|
+
expect(gaps[0].code).toBe("no_models_no_endpoints");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns early with only no_models_no_endpoints when empty (no further checks)", () => {
|
|
61
|
+
const dsl = makeDsl({ endpoints: [], models: [] });
|
|
62
|
+
const gaps = assessDslRichness(dsl);
|
|
63
|
+
// Should not also report sparse_model / missing_errors when empty
|
|
64
|
+
expect(gaps.every((g) => g.code === "no_models_no_endpoints")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns no gaps for a well-formed DSL", () => {
|
|
68
|
+
const dsl = makeDsl();
|
|
69
|
+
const gaps = assessDslRichness(dsl);
|
|
70
|
+
expect(gaps).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("detects generic_endpoint_desc when description is too short", () => {
|
|
74
|
+
const dsl = makeDsl({
|
|
75
|
+
endpoints: [makeEndpoint({ description: "Get items" })], // < 15 chars
|
|
76
|
+
});
|
|
77
|
+
const gaps = assessDslRichness(dsl);
|
|
78
|
+
expect(gaps.some((g) => g.code === "generic_endpoint_desc")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("detects generic_endpoint_desc when description starts with 'handles'", () => {
|
|
82
|
+
const dsl = makeDsl({
|
|
83
|
+
endpoints: [makeEndpoint({ description: "Handles the item request and processing" })],
|
|
84
|
+
});
|
|
85
|
+
const gaps = assessDslRichness(dsl);
|
|
86
|
+
expect(gaps.some((g) => g.code === "generic_endpoint_desc")).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("detects generic_endpoint_desc for Chinese generic verbs (管理)", () => {
|
|
90
|
+
const dsl = makeDsl({
|
|
91
|
+
endpoints: [makeEndpoint({ description: "管理商品列表的接口调用和返回" })],
|
|
92
|
+
});
|
|
93
|
+
const gaps = assessDslRichness(dsl);
|
|
94
|
+
expect(gaps.some((g) => g.code === "generic_endpoint_desc")).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("does NOT flag a sufficiently descriptive endpoint", () => {
|
|
98
|
+
const dsl = makeDsl({
|
|
99
|
+
endpoints: [makeEndpoint({ description: "Returns paginated list of active inventory items filtered by category" })],
|
|
100
|
+
});
|
|
101
|
+
const gaps = assessDslRichness(dsl);
|
|
102
|
+
expect(gaps.some((g) => g.code === "generic_endpoint_desc")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("detects missing_errors when all endpoints lack error definitions (≥2 endpoints)", () => {
|
|
106
|
+
const ep = makeEndpoint({ errors: undefined });
|
|
107
|
+
const dsl = makeDsl({ endpoints: [ep, { ...ep, id: "EP-002", path: "/api/items/:id" }] });
|
|
108
|
+
const gaps = assessDslRichness(dsl);
|
|
109
|
+
expect(gaps.some((g) => g.code === "missing_errors")).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does NOT flag missing_errors when there is only one endpoint", () => {
|
|
113
|
+
const dsl = makeDsl({
|
|
114
|
+
endpoints: [makeEndpoint({ errors: undefined })],
|
|
115
|
+
});
|
|
116
|
+
const gaps = assessDslRichness(dsl);
|
|
117
|
+
expect(gaps.some((g) => g.code === "missing_errors")).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does NOT flag missing_errors when at least one endpoint has errors", () => {
|
|
121
|
+
const withErrors = makeEndpoint();
|
|
122
|
+
const withoutErrors = makeEndpoint({ id: "EP-002", path: "/api/items/:id", errors: undefined });
|
|
123
|
+
const dsl = makeDsl({ endpoints: [withErrors, withoutErrors] });
|
|
124
|
+
const gaps = assessDslRichness(dsl);
|
|
125
|
+
expect(gaps.some((g) => g.code === "missing_errors")).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("detects sparse_model when model has fewer than 2 fields", () => {
|
|
129
|
+
const dsl = makeDsl({
|
|
130
|
+
models: [makeModel({ fields: [{ name: "id", type: "String", required: true }] })],
|
|
131
|
+
});
|
|
132
|
+
const gaps = assessDslRichness(dsl);
|
|
133
|
+
expect(gaps.some((g) => g.code === "sparse_model")).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("detects sparse_model when model has zero fields", () => {
|
|
137
|
+
const dsl = makeDsl({ models: [makeModel({ fields: [] })] });
|
|
138
|
+
const gaps = assessDslRichness(dsl);
|
|
139
|
+
expect(gaps.some((g) => g.code === "sparse_model")).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("does NOT flag sparse_model when model has 2 or more fields", () => {
|
|
143
|
+
const dsl = makeDsl({
|
|
144
|
+
models: [makeModel({
|
|
145
|
+
fields: [
|
|
146
|
+
{ name: "id", type: "String", required: true },
|
|
147
|
+
{ name: "name", type: "String", required: true },
|
|
148
|
+
],
|
|
149
|
+
})],
|
|
150
|
+
});
|
|
151
|
+
const gaps = assessDslRichness(dsl);
|
|
152
|
+
expect(gaps.some((g) => g.code === "sparse_model")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("can detect multiple gaps simultaneously", () => {
|
|
156
|
+
const dsl = makeDsl({
|
|
157
|
+
endpoints: [makeEndpoint({ description: "处理", errors: undefined }), makeEndpoint({ id: "EP-002", path: "/b", errors: undefined })],
|
|
158
|
+
models: [makeModel({ fields: [{ name: "id", type: "String", required: true }] })],
|
|
159
|
+
});
|
|
160
|
+
const codes = assessDslRichness(dsl).map((g) => g.code);
|
|
161
|
+
expect(codes).toContain("generic_endpoint_desc");
|
|
162
|
+
expect(codes).toContain("missing_errors");
|
|
163
|
+
expect(codes).toContain("sparse_model");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("gap hint is a non-empty string", () => {
|
|
167
|
+
const dsl = makeDsl({ endpoints: [], models: [] });
|
|
168
|
+
const gaps = assessDslRichness(dsl);
|
|
169
|
+
for (const gap of gaps) {
|
|
170
|
+
expect(typeof gap.hint).toBe("string");
|
|
171
|
+
expect(gap.hint.length).toBeGreaterThan(0);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ─── extractStructuralFindings ────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("extractStructuralFindings", () => {
|
|
179
|
+
const SEP = "─".repeat(52);
|
|
180
|
+
|
|
181
|
+
it("returns empty array for empty review text", () => {
|
|
182
|
+
expect(extractStructuralFindings("")).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns empty array when Pass 1 scores ≥ 8", () => {
|
|
186
|
+
const reviewText = `Architecture looks solid.\nScore: 9/10\n${SEP}\nimpl stuff`;
|
|
187
|
+
expect(extractStructuralFindings(reviewText)).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("detects auth_design finding from Chinese pattern", () => {
|
|
191
|
+
const pass1 = `The endpoint /api/users/create 缺少认证,应该加上 JWT 验证。\nScore: 5/10`;
|
|
192
|
+
const reviewText = `${pass1}\n${SEP}\nimpl notes`;
|
|
193
|
+
const findings = extractStructuralFindings(reviewText);
|
|
194
|
+
expect(findings.some((f) => f.category === "auth_design")).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("detects auth_design finding from English pattern", () => {
|
|
198
|
+
const pass1 = `The POST /orders endpoint is missing auth — it should require authentication.\nScore: 6/10`;
|
|
199
|
+
const reviewText = `${pass1}\n${SEP}\nimpl notes`;
|
|
200
|
+
const findings = extractStructuralFindings(reviewText);
|
|
201
|
+
expect(findings.some((f) => f.category === "auth_design")).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("detects api_contract finding", () => {
|
|
205
|
+
const pass1 = `接口设计问题:response 缺少 pagination 字段。\nScore: 6/10`;
|
|
206
|
+
const reviewText = `${pass1}\n${SEP}\nimpl notes`;
|
|
207
|
+
const findings = extractStructuralFindings(reviewText);
|
|
208
|
+
expect(findings.some((f) => f.category === "api_contract")).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("detects model_design finding", () => {
|
|
212
|
+
const pass1 = `模型缺少字段:Order model 没有 status 和 total 字段。\nScore: 5/10`;
|
|
213
|
+
const reviewText = `${pass1}\n${SEP}\nimpl notes`;
|
|
214
|
+
const findings = extractStructuralFindings(reviewText);
|
|
215
|
+
expect(findings.some((f) => f.category === "model_design")).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("detects layer_violation finding", () => {
|
|
219
|
+
const pass1 = `分层问题:business logic 直接写在 Controller 里,违反了 Service 层设计。\nScore: 4/10`;
|
|
220
|
+
const reviewText = `${pass1}\n${SEP}\nimpl notes`;
|
|
221
|
+
const findings = extractStructuralFindings(reviewText);
|
|
222
|
+
expect(findings.some((f) => f.category === "layer_violation")).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns empty when review text has no structural keywords but low score", () => {
|
|
226
|
+
const pass1 = `Code is a bit messy but structurally OK. Some variable names are unclear.\nScore: 6/10`;
|
|
227
|
+
const reviewText = `${pass1}\n${SEP}\nimpl notes`;
|
|
228
|
+
const findings = extractStructuralFindings(reviewText);
|
|
229
|
+
expect(findings).toHaveLength(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("each finding has a non-empty description", () => {
|
|
233
|
+
const pass1 = `缺少认证在 /api/admin endpoint 上。模型缺少字段 role。\nScore: 5/10`;
|
|
234
|
+
const reviewText = `${pass1}\n${SEP}\nimpl`;
|
|
235
|
+
const findings = extractStructuralFindings(reviewText);
|
|
236
|
+
for (const f of findings) {
|
|
237
|
+
expect(typeof f.description).toBe("string");
|
|
238
|
+
expect(f.description.length).toBeGreaterThan(0);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ─── Prompt builders (smoke tests) ────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
describe("buildDslGapRefinementPrompt", () => {
|
|
246
|
+
it("includes each gap hint in the output prompt", () => {
|
|
247
|
+
const dsl = makeDsl({ endpoints: [], models: [] });
|
|
248
|
+
const gaps = assessDslRichness(dsl);
|
|
249
|
+
const prompt = buildDslGapRefinementPrompt("# My Spec", gaps);
|
|
250
|
+
for (const gap of gaps) {
|
|
251
|
+
expect(prompt).toContain(gap.hint.slice(0, 30));
|
|
252
|
+
}
|
|
253
|
+
expect(prompt).toContain("# My Spec");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("buildStructuralAmendmentPrompt", () => {
|
|
258
|
+
it("includes each finding description in the output prompt", () => {
|
|
259
|
+
const findings = [
|
|
260
|
+
{ category: "auth_design" as const, description: "POST /users is missing authentication" },
|
|
261
|
+
];
|
|
262
|
+
const prompt = buildStructuralAmendmentPrompt("# My Spec", findings);
|
|
263
|
+
expect(prompt).toContain("POST /users is missing authentication");
|
|
264
|
+
expect(prompt).toContain("# My Spec");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { validateDsl } from "../core/dsl-validator";
|
|
3
|
+
|
|
4
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const VALID_DSL = {
|
|
7
|
+
version: "1.0",
|
|
8
|
+
feature: {
|
|
9
|
+
id: "user-login",
|
|
10
|
+
title: "User Login",
|
|
11
|
+
description: "Allows users to authenticate with email and password",
|
|
12
|
+
},
|
|
13
|
+
models: [
|
|
14
|
+
{
|
|
15
|
+
name: "User",
|
|
16
|
+
description: "Application user",
|
|
17
|
+
fields: [
|
|
18
|
+
{ name: "id", type: "String", required: true },
|
|
19
|
+
{ name: "email", type: "String", required: true, unique: true },
|
|
20
|
+
{ name: "password", type: "String", required: true },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
endpoints: [
|
|
25
|
+
{
|
|
26
|
+
id: "EP-001",
|
|
27
|
+
method: "POST",
|
|
28
|
+
path: "/api/auth/login",
|
|
29
|
+
description: "Authenticate user and return JWT token",
|
|
30
|
+
auth: false,
|
|
31
|
+
successStatus: 200,
|
|
32
|
+
successDescription: "JWT token returned",
|
|
33
|
+
request: {
|
|
34
|
+
body: { email: "string (email format)", password: "string (min 8 chars)" },
|
|
35
|
+
},
|
|
36
|
+
errors: [
|
|
37
|
+
{ status: 400, code: "INVALID_EMAIL", description: "Email format is invalid" },
|
|
38
|
+
{ status: 401, code: "INVALID_CREDENTIALS", description: "Password is incorrect" },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
behaviors: [
|
|
43
|
+
{
|
|
44
|
+
id: "BHV-001",
|
|
45
|
+
description: "Failed login attempts are rate-limited to 5 per minute",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ─── Valid DSL ────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
describe("validateDsl — valid input", () => {
|
|
53
|
+
it("accepts a well-formed DSL", () => {
|
|
54
|
+
const result = validateDsl(VALID_DSL);
|
|
55
|
+
expect(result.valid).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("accepts empty models array", () => {
|
|
59
|
+
const result = validateDsl({ ...VALID_DSL, models: [] });
|
|
60
|
+
expect(result.valid).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("accepts empty endpoints array", () => {
|
|
64
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [] });
|
|
65
|
+
expect(result.valid).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("accepts empty behaviors array", () => {
|
|
69
|
+
const result = validateDsl({ ...VALID_DSL, behaviors: [] });
|
|
70
|
+
expect(result.valid).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("accepts DSL without optional components field", () => {
|
|
74
|
+
const { ...dsl } = VALID_DSL;
|
|
75
|
+
const result = validateDsl(dsl);
|
|
76
|
+
expect(result.valid).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns the typed DSL on success", () => {
|
|
80
|
+
const result = validateDsl(VALID_DSL);
|
|
81
|
+
if (result.valid) {
|
|
82
|
+
expect(result.dsl.feature.id).toBe("user-login");
|
|
83
|
+
expect(result.dsl.endpoints[0].method).toBe("POST");
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("accepts all valid HTTP methods", () => {
|
|
88
|
+
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"] as const) {
|
|
89
|
+
const ep = { ...VALID_DSL.endpoints[0], method };
|
|
90
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep] });
|
|
91
|
+
expect(result.valid).toBe(true);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ─── Invalid root structure ───────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
describe("validateDsl — root structure errors", () => {
|
|
99
|
+
it("rejects null", () => {
|
|
100
|
+
const result = validateDsl(null);
|
|
101
|
+
expect(result.valid).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("rejects a plain array", () => {
|
|
105
|
+
const result = validateDsl([]);
|
|
106
|
+
expect(result.valid).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("rejects a string", () => {
|
|
110
|
+
const result = validateDsl("not-an-object");
|
|
111
|
+
expect(result.valid).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("rejects wrong version", () => {
|
|
115
|
+
const result = validateDsl({ ...VALID_DSL, version: "2.0" });
|
|
116
|
+
expect(result.valid).toBe(false);
|
|
117
|
+
if (!result.valid) {
|
|
118
|
+
expect(result.errors.some((e) => e.path === "version")).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── Feature validation ───────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
describe("validateDsl — feature validation", () => {
|
|
126
|
+
it("rejects missing feature.id", () => {
|
|
127
|
+
const result = validateDsl({ ...VALID_DSL, feature: { ...VALID_DSL.feature, id: "" } });
|
|
128
|
+
expect(result.valid).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects missing feature.title", () => {
|
|
132
|
+
const result = validateDsl({ ...VALID_DSL, feature: { ...VALID_DSL.feature, title: "" } });
|
|
133
|
+
expect(result.valid).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects missing feature.description", () => {
|
|
137
|
+
const result = validateDsl({ ...VALID_DSL, feature: { ...VALID_DSL.feature, description: "" } });
|
|
138
|
+
expect(result.valid).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects non-object feature", () => {
|
|
142
|
+
const result = validateDsl({ ...VALID_DSL, feature: "login" });
|
|
143
|
+
expect(result.valid).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ─── Endpoint validation ──────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
describe("validateDsl — endpoint validation", () => {
|
|
150
|
+
it("rejects invalid HTTP method", () => {
|
|
151
|
+
const ep = { ...VALID_DSL.endpoints[0], method: "CONNECT" };
|
|
152
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep] });
|
|
153
|
+
expect(result.valid).toBe(false);
|
|
154
|
+
if (!result.valid) {
|
|
155
|
+
expect(result.errors.some((e) => e.path.includes("method"))).toBe(true);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("rejects path not starting with /", () => {
|
|
160
|
+
const ep = { ...VALID_DSL.endpoints[0], path: "api/login" };
|
|
161
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep] });
|
|
162
|
+
expect(result.valid).toBe(false);
|
|
163
|
+
if (!result.valid) {
|
|
164
|
+
expect(result.errors.some((e) => e.path.includes("path"))).toBe(true);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("rejects non-boolean auth", () => {
|
|
169
|
+
const ep = { ...VALID_DSL.endpoints[0], auth: "yes" };
|
|
170
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep] });
|
|
171
|
+
expect(result.valid).toBe(false);
|
|
172
|
+
if (!result.valid) {
|
|
173
|
+
expect(result.errors.some((e) => e.path.includes("auth"))).toBe(true);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("rejects out-of-range successStatus", () => {
|
|
178
|
+
const ep = { ...VALID_DSL.endpoints[0], successStatus: 99 };
|
|
179
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep] });
|
|
180
|
+
expect(result.valid).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("rejects successStatus > 599", () => {
|
|
184
|
+
const ep = { ...VALID_DSL.endpoints[0], successStatus: 600 };
|
|
185
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep] });
|
|
186
|
+
expect(result.valid).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("rejects missing endpoint description", () => {
|
|
190
|
+
const ep = { ...VALID_DSL.endpoints[0], description: "" };
|
|
191
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep] });
|
|
192
|
+
expect(result.valid).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("rejects non-string FieldMap value", () => {
|
|
196
|
+
const ep = {
|
|
197
|
+
...VALID_DSL.endpoints[0],
|
|
198
|
+
request: { body: { email: { nested: "object" } } },
|
|
199
|
+
};
|
|
200
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep] });
|
|
201
|
+
expect(result.valid).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("rejects error entry with invalid status code", () => {
|
|
205
|
+
const ep = {
|
|
206
|
+
...VALID_DSL.endpoints[0],
|
|
207
|
+
errors: [{ status: 999, code: "BAD", description: "Bad" }],
|
|
208
|
+
};
|
|
209
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep] });
|
|
210
|
+
expect(result.valid).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("rejects endpoints array exceeding MAX_ENDPOINTS (100)", () => {
|
|
214
|
+
const eps = Array.from({ length: 101 }, (_, i) => ({
|
|
215
|
+
...VALID_DSL.endpoints[0],
|
|
216
|
+
id: `EP-${i.toString().padStart(3, "0")}`,
|
|
217
|
+
path: `/api/route-${i}`,
|
|
218
|
+
}));
|
|
219
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: eps });
|
|
220
|
+
expect(result.valid).toBe(false);
|
|
221
|
+
if (!result.valid) {
|
|
222
|
+
expect(result.errors.some((e) => e.path === "endpoints")).toBe(true);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ─── Model validation ─────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe("validateDsl — model validation", () => {
|
|
230
|
+
it("rejects model with missing name", () => {
|
|
231
|
+
const model = { ...VALID_DSL.models[0], name: "" };
|
|
232
|
+
const result = validateDsl({ ...VALID_DSL, models: [model] });
|
|
233
|
+
expect(result.valid).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("rejects model field with non-boolean required", () => {
|
|
237
|
+
const model = {
|
|
238
|
+
...VALID_DSL.models[0],
|
|
239
|
+
fields: [{ name: "id", type: "String", required: "yes" }],
|
|
240
|
+
};
|
|
241
|
+
const result = validateDsl({ ...VALID_DSL, models: [model] });
|
|
242
|
+
expect(result.valid).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("rejects model with non-array fields", () => {
|
|
246
|
+
const model = { ...VALID_DSL.models[0], fields: "not-an-array" };
|
|
247
|
+
const result = validateDsl({ ...VALID_DSL, models: [model] });
|
|
248
|
+
expect(result.valid).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("rejects models array exceeding MAX_MODELS (50)", () => {
|
|
252
|
+
const models = Array.from({ length: 51 }, (_, i) => ({
|
|
253
|
+
...VALID_DSL.models[0],
|
|
254
|
+
name: `Model${i}`,
|
|
255
|
+
}));
|
|
256
|
+
const result = validateDsl({ ...VALID_DSL, models });
|
|
257
|
+
expect(result.valid).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("rejects non-string relation entry", () => {
|
|
261
|
+
const model = { ...VALID_DSL.models[0], relations: [42] };
|
|
262
|
+
const result = validateDsl({ ...VALID_DSL, models: [model] });
|
|
263
|
+
expect(result.valid).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ─── Error collection (all errors in one pass) ───────────────────────────────
|
|
268
|
+
|
|
269
|
+
describe("validateDsl — error accumulation", () => {
|
|
270
|
+
it("collects multiple errors in a single validation run", () => {
|
|
271
|
+
const result = validateDsl({
|
|
272
|
+
version: "2.0", // wrong version
|
|
273
|
+
feature: { id: "", title: "", description: "" }, // all empty
|
|
274
|
+
models: [],
|
|
275
|
+
endpoints: [],
|
|
276
|
+
behaviors: [],
|
|
277
|
+
});
|
|
278
|
+
expect(result.valid).toBe(false);
|
|
279
|
+
if (!result.valid) {
|
|
280
|
+
expect(result.errors.length).toBeGreaterThan(1);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
});
|