ai-spec-dev 0.46.0 → 0.56.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/README.md +60 -30
- package/cli/commands/config.ts +129 -1
- package/cli/commands/create.ts +14 -0
- package/cli/commands/fix-history.ts +176 -0
- package/cli/commands/init.ts +36 -1
- package/cli/index.ts +2 -6
- package/cli/pipeline/helpers.ts +6 -0
- package/cli/pipeline/multi-repo.ts +300 -26
- package/cli/pipeline/single-repo.ts +103 -2
- package/cli/utils.ts +23 -0
- package/core/code-generator.ts +63 -14
- package/core/cross-stack-verifier.ts +482 -0
- package/core/fix-history.ts +333 -0
- package/core/import-fixer.ts +827 -0
- package/core/import-verifier.ts +569 -0
- package/core/knowledge-memory.ts +55 -6
- package/core/self-evaluator.ts +44 -7
- package/core/spec-generator.ts +3 -3
- package/core/types-generator.ts +2 -2
- package/dist/cli/index.js +3968 -2353
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +3810 -2195
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +249 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +249 -128
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/tests/cross-stack-verifier.test.ts +402 -0
- package/tests/fix-history.test.ts +335 -0
- package/tests/import-fixer.test.ts +944 -0
- package/tests/import-verifier.test.ts +420 -0
- package/tests/knowledge-memory.test.ts +40 -0
- package/tests/self-evaluator.test.ts +97 -0
- package/.ai-spec-workspace.json +0 -17
- package/.ai-spec.json +0 -7
- package/cli/commands/model.ts +0 -152
- package/cli/commands/scan.ts +0 -99
- package/cli/commands/workspace.ts +0 -219
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-spec-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"description": "AI-driven Development Orchestrator SDK & CLI",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"gemini",
|
|
21
21
|
"claude"
|
|
22
22
|
],
|
|
23
|
-
"author": "",
|
|
23
|
+
"author": "hongzhong",
|
|
24
24
|
"license": "MIT",
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sdk": "^0.38.0",
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "fs-extra";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import {
|
|
6
|
+
extractApiCallsFromSource,
|
|
7
|
+
normalizePathSegments,
|
|
8
|
+
pathsMatch,
|
|
9
|
+
verifyCrossStackContract,
|
|
10
|
+
} from "../core/cross-stack-verifier";
|
|
11
|
+
import type { SpecDSL } from "../core/dsl-types";
|
|
12
|
+
|
|
13
|
+
// ─── extractApiCallsFromSource ────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
describe("extractApiCallsFromSource", () => {
|
|
16
|
+
it("extracts axios.get calls", () => {
|
|
17
|
+
const src = `import axios from 'axios';\nconst r = await axios.get('/api/users');`;
|
|
18
|
+
const calls = extractApiCallsFromSource(src, "src/api/user.ts");
|
|
19
|
+
expect(calls).toHaveLength(1);
|
|
20
|
+
expect(calls[0]).toMatchObject({ method: "GET", path: "/api/users", file: "src/api/user.ts" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("extracts axios.post with template literal path", () => {
|
|
24
|
+
const src = "await axios.post(`/api/users/${id}/roles`, body);";
|
|
25
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
26
|
+
expect(calls).toHaveLength(1);
|
|
27
|
+
expect(calls[0].method).toBe("POST");
|
|
28
|
+
expect(calls[0].path).toBe("/api/users/${id}/roles");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("extracts fetch with inline method option", () => {
|
|
32
|
+
const src = `const r = await fetch('/api/orders', { method: 'POST', body });`;
|
|
33
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
34
|
+
expect(calls).toHaveLength(1);
|
|
35
|
+
expect(calls[0]).toMatchObject({ method: "POST", path: "/api/orders" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("defaults fetch to GET when no method option", () => {
|
|
39
|
+
const src = `const r = await fetch('/api/orders');`;
|
|
40
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
41
|
+
expect(calls[0].method).toBe("GET");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("extracts useRequest calls with method option", () => {
|
|
45
|
+
const src = `const { data } = useRequest('/api/items', { method: 'DELETE' });`;
|
|
46
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
47
|
+
expect(calls[0]).toMatchObject({ method: "DELETE", path: "/api/items" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("extracts generic request('/path', 'POST') helper", () => {
|
|
51
|
+
const src = `await request('/api/login', 'POST')`;
|
|
52
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
53
|
+
expect(calls[0]).toMatchObject({ method: "POST", path: "/api/login" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("skips non-API string literals (CSS imports, assets)", () => {
|
|
57
|
+
const src = `import css from './style.css';\nconst logo = '/images/logo.png';`;
|
|
58
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
59
|
+
expect(calls).toHaveLength(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("finds multiple calls in one file with correct line numbers", () => {
|
|
63
|
+
const src = [
|
|
64
|
+
"// line 1",
|
|
65
|
+
"import axios from 'axios';",
|
|
66
|
+
"axios.get('/api/users');", // line 3
|
|
67
|
+
"",
|
|
68
|
+
"axios.post('/api/users', body);", // line 5
|
|
69
|
+
].join("\n");
|
|
70
|
+
const calls = extractApiCallsFromSource(src, "x.ts");
|
|
71
|
+
expect(calls).toHaveLength(2);
|
|
72
|
+
expect(calls[0].line).toBe(3);
|
|
73
|
+
expect(calls[1].line).toBe(5);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("marks pure request('/path') calls as UNKNOWN method", () => {
|
|
77
|
+
const src = `await request('/api/raw');`;
|
|
78
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
79
|
+
expect(calls[0].method).toBe("UNKNOWN");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("extracts axios.get('/api/prefix/' + variable) as concat path", () => {
|
|
83
|
+
const src = `axios.get('/api/users/' + userId)`;
|
|
84
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
85
|
+
expect(calls).toHaveLength(1);
|
|
86
|
+
expect(calls[0].method).toBe("GET");
|
|
87
|
+
expect(calls[0].isConcatPath).toBe(true);
|
|
88
|
+
// Path should end with /* wildcard
|
|
89
|
+
expect(calls[0].path).toBe("/api/users/*");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("extracts axios.post('/api/prefix/' + variable) as concat path with correct method", () => {
|
|
93
|
+
const src = `axios.post('/api/orders/' + id, body)`;
|
|
94
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
95
|
+
const concatCall = calls.find((c) => c.isConcatPath);
|
|
96
|
+
expect(concatCall).toBeDefined();
|
|
97
|
+
expect(concatCall!.method).toBe("POST");
|
|
98
|
+
expect(concatCall!.path).toBe("/api/orders/*");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does NOT double-count full-literal paths as concat", () => {
|
|
102
|
+
// '/api/users/' is the full path (no + follows), should not be marked concat
|
|
103
|
+
const src = `axios.get('/api/users/');`;
|
|
104
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
105
|
+
expect(calls.every((c) => !c.isConcatPath)).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("extracts fetch('/api/prefix/' + variable) as concat path", () => {
|
|
109
|
+
const src = `fetch('/api/items/' + id, { method: 'DELETE' })`;
|
|
110
|
+
const calls = extractApiCallsFromSource(src, "a.ts");
|
|
111
|
+
const concatCall = calls.find((c) => c.isConcatPath);
|
|
112
|
+
expect(concatCall).toBeDefined();
|
|
113
|
+
expect(concatCall!.method).toBe("DELETE");
|
|
114
|
+
expect(concatCall!.path).toBe("/api/items/*");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ─── Path normalization & matching ────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
describe("normalizePathSegments", () => {
|
|
121
|
+
it("wildcards :id segments", () => {
|
|
122
|
+
expect(normalizePathSegments("/api/users/:id")).toEqual(["api", "users", "*"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("wildcards template literal slots", () => {
|
|
126
|
+
expect(normalizePathSegments("/api/users/${id}/roles")).toEqual(["api", "users", "*", "roles"]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("wildcards numeric id segments", () => {
|
|
130
|
+
expect(normalizePathSegments("/api/users/123")).toEqual(["api", "users", "*"]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("strips querystring", () => {
|
|
134
|
+
expect(normalizePathSegments("/api/search?q=foo")).toEqual(["api", "search"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("preserves static segments lowercased", () => {
|
|
138
|
+
expect(normalizePathSegments("/API/Users")).toEqual(["api", "users"]);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("pathsMatch", () => {
|
|
143
|
+
it("matches DSL :id against frontend ${id}", () => {
|
|
144
|
+
expect(pathsMatch("/api/users/:id", "/api/users/${userId}")).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("matches DSL :id against numeric literal", () => {
|
|
148
|
+
expect(pathsMatch("/api/users/:id", "/api/users/42")).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("rejects different lengths", () => {
|
|
152
|
+
expect(pathsMatch("/api/users", "/api/users/:id")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("rejects different static segments", () => {
|
|
156
|
+
expect(pathsMatch("/api/users/:id", "/api/orders/:id")).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("rejects singular vs plural", () => {
|
|
160
|
+
expect(pathsMatch("/api/users/:id", "/api/user/:id")).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ─── verifyCrossStackContract (end-to-end with tmp dir) ───────────────────────
|
|
165
|
+
|
|
166
|
+
describe("verifyCrossStackContract", () => {
|
|
167
|
+
let tmpDir: string;
|
|
168
|
+
|
|
169
|
+
beforeEach(async () => {
|
|
170
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "xstack-"));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
afterEach(async () => {
|
|
174
|
+
await fs.remove(tmpDir);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const buildDsl = (endpoints: Array<{ id: string; method: string; path: string }>): SpecDSL => ({
|
|
178
|
+
version: "1.0",
|
|
179
|
+
feature: { id: "f", title: "T", description: "D" },
|
|
180
|
+
models: [],
|
|
181
|
+
endpoints: endpoints.map((e) => ({
|
|
182
|
+
id: e.id,
|
|
183
|
+
method: e.method as "GET" | "POST" | "PUT" | "PATCH" | "DELETE",
|
|
184
|
+
path: e.path,
|
|
185
|
+
description: "",
|
|
186
|
+
auth: false,
|
|
187
|
+
successStatus: 200,
|
|
188
|
+
successDescription: "ok",
|
|
189
|
+
})),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("reports fully matched contract when frontend uses all endpoints correctly", async () => {
|
|
193
|
+
await fs.writeFile(
|
|
194
|
+
path.join(tmpDir, "api.ts"),
|
|
195
|
+
`axios.get('/api/users');\naxios.post('/api/users', body);`
|
|
196
|
+
);
|
|
197
|
+
const dsl = buildDsl([
|
|
198
|
+
{ id: "EP-1", method: "GET", path: "/api/users" },
|
|
199
|
+
{ id: "EP-2", method: "POST", path: "/api/users" },
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
203
|
+
expect(report.matched).toHaveLength(2);
|
|
204
|
+
expect(report.phantom).toHaveLength(0);
|
|
205
|
+
expect(report.methodMismatch).toHaveLength(0);
|
|
206
|
+
expect(report.unused).toHaveLength(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("flags phantom endpoints when frontend calls a path not in DSL", async () => {
|
|
210
|
+
await fs.writeFile(
|
|
211
|
+
path.join(tmpDir, "api.ts"),
|
|
212
|
+
`axios.get('/api/ghost');\naxios.get('/api/users');`
|
|
213
|
+
);
|
|
214
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/api/users" }]);
|
|
215
|
+
|
|
216
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
217
|
+
expect(report.phantom).toHaveLength(1);
|
|
218
|
+
expect(report.phantom[0].path).toBe("/api/ghost");
|
|
219
|
+
expect(report.matched).toHaveLength(1);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("flags method mismatch when path matches but method differs", async () => {
|
|
223
|
+
await fs.writeFile(
|
|
224
|
+
path.join(tmpDir, "api.ts"),
|
|
225
|
+
`axios.get('/api/users');` // DSL says POST
|
|
226
|
+
);
|
|
227
|
+
const dsl = buildDsl([{ id: "EP-1", method: "POST", path: "/api/users" }]);
|
|
228
|
+
|
|
229
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
230
|
+
expect(report.methodMismatch).toHaveLength(1);
|
|
231
|
+
expect(report.methodMismatch[0].expectedMethod).toBe("POST");
|
|
232
|
+
expect(report.methodMismatch[0].call.method).toBe("GET");
|
|
233
|
+
expect(report.phantom).toHaveLength(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("flags unused endpoints when DSL declares more than frontend consumes", async () => {
|
|
237
|
+
await fs.writeFile(
|
|
238
|
+
path.join(tmpDir, "api.ts"),
|
|
239
|
+
`axios.get('/api/users');`
|
|
240
|
+
);
|
|
241
|
+
const dsl = buildDsl([
|
|
242
|
+
{ id: "EP-1", method: "GET", path: "/api/users" },
|
|
243
|
+
{ id: "EP-2", method: "POST", path: "/api/users" },
|
|
244
|
+
{ id: "EP-3", method: "DELETE", path: "/api/users/:id" },
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
248
|
+
expect(report.matched).toHaveLength(1);
|
|
249
|
+
expect(report.unused).toHaveLength(2);
|
|
250
|
+
expect(report.unused.map((u) => u.id).sort()).toEqual(["EP-2", "EP-3"]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("matches DSL :id endpoints against template-literal and numeric frontend calls", async () => {
|
|
254
|
+
await fs.writeFile(
|
|
255
|
+
path.join(tmpDir, "api.ts"),
|
|
256
|
+
"axios.get(`/api/users/${id}`);\naxios.delete('/api/users/42');"
|
|
257
|
+
);
|
|
258
|
+
const dsl = buildDsl([
|
|
259
|
+
{ id: "EP-1", method: "GET", path: "/api/users/:id" },
|
|
260
|
+
{ id: "EP-2", method: "DELETE", path: "/api/users/:id" },
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
264
|
+
expect(report.matched).toHaveLength(2);
|
|
265
|
+
expect(report.phantom).toHaveLength(0);
|
|
266
|
+
expect(report.unused).toHaveLength(0);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("skips node_modules and dist folders", async () => {
|
|
270
|
+
await fs.ensureDir(path.join(tmpDir, "node_modules/foo"));
|
|
271
|
+
await fs.writeFile(
|
|
272
|
+
path.join(tmpDir, "node_modules/foo/index.ts"),
|
|
273
|
+
`axios.get('/api/should-be-ignored');`
|
|
274
|
+
);
|
|
275
|
+
await fs.writeFile(
|
|
276
|
+
path.join(tmpDir, "real.ts"),
|
|
277
|
+
`axios.get('/api/users');`
|
|
278
|
+
);
|
|
279
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/api/users" }]);
|
|
280
|
+
|
|
281
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
282
|
+
expect(report.matched).toHaveLength(1);
|
|
283
|
+
expect(report.phantom).toHaveLength(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("scopedFiles: only scans the listed files, ignoring pre-existing repo code", async () => {
|
|
287
|
+
// Pre-existing frontend code with unrelated API calls (simulates rushbuy case)
|
|
288
|
+
await fs.writeFile(
|
|
289
|
+
path.join(tmpDir, "legacy.ts"),
|
|
290
|
+
`axios.post('/api/youpin/deposit/service');`
|
|
291
|
+
);
|
|
292
|
+
await fs.writeFile(
|
|
293
|
+
path.join(tmpDir, "legacy2.ts"),
|
|
294
|
+
`axios.get('/api/refund/records/export');`
|
|
295
|
+
);
|
|
296
|
+
// Newly generated file (in scope) that correctly uses the DSL endpoint
|
|
297
|
+
const generated = path.join(tmpDir, "src/apis/task/index.ts");
|
|
298
|
+
await fs.ensureDir(path.dirname(generated));
|
|
299
|
+
await fs.writeFile(generated, `axios.get('/admin/tasks');`);
|
|
300
|
+
|
|
301
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/admin/tasks" }]);
|
|
302
|
+
|
|
303
|
+
// Without scoping, the 2 legacy calls show as phantom
|
|
304
|
+
const unscoped = await verifyCrossStackContract(dsl, tmpDir);
|
|
305
|
+
expect(unscoped.phantom.length).toBeGreaterThanOrEqual(2);
|
|
306
|
+
|
|
307
|
+
// With scoping, only the generated file is checked — clean report
|
|
308
|
+
const scoped = await verifyCrossStackContract(dsl, tmpDir, {
|
|
309
|
+
scopedFiles: [generated],
|
|
310
|
+
});
|
|
311
|
+
expect(scoped.phantom).toHaveLength(0);
|
|
312
|
+
expect(scoped.matched).toHaveLength(1);
|
|
313
|
+
expect(scoped.totalScannedFiles).toBe(1);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("scopedFiles: accepts relative paths resolved against frontendRoot", async () => {
|
|
317
|
+
await fs.ensureDir(path.join(tmpDir, "src"));
|
|
318
|
+
await fs.writeFile(path.join(tmpDir, "src/x.ts"), `axios.get('/api/users');`);
|
|
319
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/api/users" }]);
|
|
320
|
+
|
|
321
|
+
const report = await verifyCrossStackContract(dsl, tmpDir, {
|
|
322
|
+
scopedFiles: ["src/x.ts"],
|
|
323
|
+
});
|
|
324
|
+
expect(report.matched).toHaveLength(1);
|
|
325
|
+
expect(report.totalScannedFiles).toBe(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("scopedFiles: empty list falls back to full scan", async () => {
|
|
329
|
+
await fs.writeFile(path.join(tmpDir, "a.ts"), `axios.get('/api/users');`);
|
|
330
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/api/users" }]);
|
|
331
|
+
|
|
332
|
+
const report = await verifyCrossStackContract(dsl, tmpDir, { scopedFiles: [] });
|
|
333
|
+
// Empty list is treated as "no scope" → walks whole tree
|
|
334
|
+
expect(report.matched).toHaveLength(1);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("hasViolations is false when contract is clean", async () => {
|
|
338
|
+
await fs.writeFile(path.join(tmpDir, "a.ts"), `axios.get('/api/users');`);
|
|
339
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/api/users" }]);
|
|
340
|
+
|
|
341
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
342
|
+
expect(report.hasViolations).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("hasViolations is true when there are phantom calls", async () => {
|
|
346
|
+
await fs.writeFile(path.join(tmpDir, "a.ts"), `axios.get('/api/ghost');`);
|
|
347
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/api/users" }]);
|
|
348
|
+
|
|
349
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
350
|
+
expect(report.hasViolations).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("hasViolations is true when there are method mismatches", async () => {
|
|
354
|
+
await fs.writeFile(path.join(tmpDir, "a.ts"), `axios.get('/api/users');`);
|
|
355
|
+
const dsl = buildDsl([{ id: "EP-1", method: "POST", path: "/api/users" }]);
|
|
356
|
+
|
|
357
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
358
|
+
expect(report.hasViolations).toBe(true);
|
|
359
|
+
expect(report.methodMismatch).toHaveLength(1);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("unknownMethodCalls is populated for UNKNOWN method calls", async () => {
|
|
363
|
+
await fs.writeFile(
|
|
364
|
+
path.join(tmpDir, "a.ts"),
|
|
365
|
+
`request('/api/users'); axios.get('/api/users');`
|
|
366
|
+
);
|
|
367
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/api/users" }]);
|
|
368
|
+
|
|
369
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
370
|
+
expect(report.unknownMethodCalls).toHaveLength(1);
|
|
371
|
+
expect(report.unknownMethodCalls[0].method).toBe("UNKNOWN");
|
|
372
|
+
// UNKNOWN is matched permissively — not a violation
|
|
373
|
+
expect(report.hasViolations).toBe(false);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("matches concat path axios.get('/api/users/' + id) against DSL /api/users/:id", async () => {
|
|
377
|
+
await fs.writeFile(
|
|
378
|
+
path.join(tmpDir, "a.ts"),
|
|
379
|
+
"axios.get('/api/users/' + userId);"
|
|
380
|
+
);
|
|
381
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/api/users/:id" }]);
|
|
382
|
+
|
|
383
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
384
|
+
expect(report.phantom).toHaveLength(0);
|
|
385
|
+
expect(report.matched).toHaveLength(1);
|
|
386
|
+
expect(report.matched[0].call.isConcatPath).toBe(true);
|
|
387
|
+
expect(report.hasViolations).toBe(false);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("flags concat path as phantom when no DSL endpoint matches the static prefix", async () => {
|
|
391
|
+
await fs.writeFile(
|
|
392
|
+
path.join(tmpDir, "a.ts"),
|
|
393
|
+
"axios.get('/api/ghost/' + id);"
|
|
394
|
+
);
|
|
395
|
+
const dsl = buildDsl([{ id: "EP-1", method: "GET", path: "/api/users/:id" }]);
|
|
396
|
+
|
|
397
|
+
const report = await verifyCrossStackContract(dsl, tmpDir);
|
|
398
|
+
expect(report.phantom).toHaveLength(1);
|
|
399
|
+
expect(report.phantom[0].isConcatPath).toBe(true);
|
|
400
|
+
expect(report.hasViolations).toBe(true);
|
|
401
|
+
});
|
|
402
|
+
});
|