@skyramp/mcp 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/prompts/enhance-assertions/contractProviderAssertionsPrompt.js +28 -110
- package/build/prompts/enhance-assertions/integrationAssertionsPrompt.js +35 -128
- package/build/prompts/enhance-assertions/sharedAssertionRules.js +212 -0
- package/build/prompts/enhance-assertions/uiAssertionsPrompt.js +217 -78
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +24 -19
- package/build/prompts/test-recommendation/recommendationSections.js +1 -1
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +48 -6
- package/build/prompts/test-recommendation/test-recommendation-prompt.test.js +139 -0
- package/build/services/TestDiscoveryService.js +22 -7
- package/build/services/TestDiscoveryService.test.js +44 -0
- package/build/tools/code-refactor/enhanceAssertionsTool.js +8 -10
- package/build/tools/test-management/analyzeChangesTool.js +259 -140
- package/build/tools/test-management/analyzeChangesTool.test.js +3 -1
- package/build/tools/test-management/analyzeTestHealthTool.js +5 -0
- package/build/types/RepositoryAnalysis.js +8 -0
- package/build/utils/branchDiff.js +24 -8
- package/build/utils/docker.test.js +1 -1
- package/build/utils/repoScanner.js +16 -2
- package/build/utils/routeParsers.js +79 -79
- package/build/utils/routeParsers.test.js +192 -66
- package/build/utils/scenarioDrafting.js +10 -2
- package/build/utils/versions.js +1 -1
- package/package.json +2 -2
|
@@ -82,6 +82,18 @@ export function parseRouteLine(line, sourceFile) {
|
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
+
// Next.js guard clause: `if (req.method !== "POST")` means the handler IS for POST
|
|
86
|
+
const nextjsGuardMatch = stripped.match(/req\.method\s*!==?\s*["'](GET|POST|PUT|PATCH|DELETE)["']/i);
|
|
87
|
+
if (nextjsGuardMatch) {
|
|
88
|
+
const apiPath = nextjsFileToApiPath(sourceFile);
|
|
89
|
+
if (apiPath) {
|
|
90
|
+
return {
|
|
91
|
+
method: nextjsGuardMatch[1].toUpperCase(),
|
|
92
|
+
path: apiPath,
|
|
93
|
+
sourceFile,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
85
97
|
const appRouterExportMatch = stripped.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/i);
|
|
86
98
|
if (appRouterExportMatch) {
|
|
87
99
|
const apiPath = nextjsFileToApiPath(sourceFile);
|
|
@@ -267,46 +279,75 @@ export function parseFileEndpoints(content, sourceFile) {
|
|
|
267
279
|
}
|
|
268
280
|
return results;
|
|
269
281
|
}
|
|
270
|
-
export
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
282
|
+
export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
|
|
283
|
+
/** Extract the primary resource name from an endpoint path (e.g. "/api/v1/orders/{id}" → "orders"). */
|
|
284
|
+
export function extractResourceFromPath(endpointPath) {
|
|
285
|
+
const segments = endpointPath.split("/").filter(Boolean);
|
|
286
|
+
const meaningful = segments.filter((s) => !s.startsWith("{") && !SKIP_PATH_SEGMENTS.has(s));
|
|
287
|
+
return meaningful[meaningful.length - 1] || "unknown";
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Classify endpoints by cross-referencing `changedFiles` against `scannedEndpoints[].sourceFile`.
|
|
291
|
+
*
|
|
292
|
+
* This replaces `parseEndpointsFromDiff` — instead of regex-parsing diff hunks for route
|
|
293
|
+
* annotations (fragile for 15+ frameworks), it uses the already-scanned endpoint catalog
|
|
294
|
+
* which has full, resolved paths and concrete HTTP methods.
|
|
295
|
+
*
|
|
296
|
+
* For deleted files, the caller must supply endpoints recovered from the base branch
|
|
297
|
+
* (via `parseFileEndpoints` on `git show base:<file>`) in `deletedFileEndpoints`.
|
|
298
|
+
*/
|
|
299
|
+
export function classifyEndpointsByChangedFiles(diffData, scannedEndpoints, deletedFileEndpoints) {
|
|
300
|
+
const newFileSet = new Set(diffData.newFiles);
|
|
301
|
+
const deletedFileSet = new Set(diffData.deletedFiles);
|
|
302
|
+
// Build sourceFile → ScannedEndpoint[] map from the post-change catalog
|
|
303
|
+
const bySourceFile = new Map();
|
|
304
|
+
for (const ep of scannedEndpoints) {
|
|
305
|
+
const existing = bySourceFile.get(ep.sourceFile);
|
|
306
|
+
if (existing)
|
|
307
|
+
existing.push(ep);
|
|
308
|
+
else
|
|
309
|
+
bySourceFile.set(ep.sourceFile, [ep]);
|
|
291
310
|
}
|
|
292
|
-
const
|
|
311
|
+
const changedEndpoints = [];
|
|
312
|
+
const newEndpoints = [];
|
|
313
|
+
const unmatchedFiles = [];
|
|
293
314
|
for (const file of diffData.changedFiles) {
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
|
|
315
|
+
// Deleted files won't appear in scanned catalog — handled separately below
|
|
316
|
+
if (deletedFileSet.has(file))
|
|
317
|
+
continue;
|
|
318
|
+
const eps = bySourceFile.get(file);
|
|
319
|
+
if (!eps || eps.length === 0) {
|
|
320
|
+
unmatchedFiles.push(file);
|
|
321
|
+
continue;
|
|
298
322
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const modifiedEndpoints = [];
|
|
302
|
-
for (const ep of addedRoutes) {
|
|
303
|
-
if (removedKeys.has(`${ep.method} ${ep.path}`)) {
|
|
304
|
-
modifiedEndpoints.push(ep);
|
|
323
|
+
if (newFileSet.has(file)) {
|
|
324
|
+
newEndpoints.push(...eps);
|
|
305
325
|
}
|
|
306
326
|
else {
|
|
307
|
-
|
|
327
|
+
changedEndpoints.push(...eps);
|
|
308
328
|
}
|
|
309
329
|
}
|
|
330
|
+
// Removed endpoints: from deleted files, recovered from the base branch.
|
|
331
|
+
// Filter out any endpoint that still exists in the current scanned catalog
|
|
332
|
+
// (i.e. was moved/refactored to another file, not truly deleted).
|
|
333
|
+
// Paths with a MULTI sentinel in the catalog are treated as present for all methods
|
|
334
|
+
// (Next.js catch-all handlers, Java @RequestMapping without explicit method, etc.).
|
|
335
|
+
const currentEndpointKeys = new Set(scannedEndpoints.flatMap((ep) => ep.methods.map((m) => `${m} ${ep.path}`)));
|
|
336
|
+
const multiPaths = new Set(scannedEndpoints.filter((ep) => ep.methods.includes("MULTI")).map((ep) => ep.path));
|
|
337
|
+
const currentPaths = new Set(scannedEndpoints.map((ep) => ep.path));
|
|
338
|
+
const removedEndpoints = (deletedFileEndpoints ?? [])
|
|
339
|
+
.map((ep) => ({
|
|
340
|
+
...ep,
|
|
341
|
+
methods: ep.methods.filter((m) => {
|
|
342
|
+
// A deleted MULTI means the file was a catch-all (e.g. Next.js default export).
|
|
343
|
+
// If the path exists in the current catalog with any method, it was moved — not removed.
|
|
344
|
+
if (m === "MULTI")
|
|
345
|
+
return !currentPaths.has(ep.path);
|
|
346
|
+
return !currentEndpointKeys.has(`${m} ${ep.path}`) && !multiPaths.has(ep.path);
|
|
347
|
+
}),
|
|
348
|
+
}))
|
|
349
|
+
.filter((ep) => ep.methods.length > 0);
|
|
350
|
+
// Affected services: same heuristic as before
|
|
310
351
|
const servicePattern = /(?:services?|modules?|apps?)\/([a-z0-9_-]+)/i;
|
|
311
352
|
const affectedServices = [
|
|
312
353
|
...new Set(diffData.changedFiles
|
|
@@ -314,54 +355,13 @@ export function parseEndpointsFromDiff(diffData) {
|
|
|
314
355
|
.filter((s) => !!s)),
|
|
315
356
|
];
|
|
316
357
|
return {
|
|
358
|
+
changedEndpoints,
|
|
359
|
+
newEndpoints,
|
|
360
|
+
removedEndpoints,
|
|
361
|
+
unmatchedFiles,
|
|
362
|
+
changedFiles: diffData.changedFiles,
|
|
317
363
|
currentBranch: diffData.currentBranch,
|
|
318
364
|
baseBranch: diffData.baseBranch,
|
|
319
|
-
changedFiles: diffData.changedFiles,
|
|
320
|
-
diffStat: diffData.diffStat,
|
|
321
|
-
newEndpoints,
|
|
322
|
-
modifiedEndpoints,
|
|
323
365
|
affectedServices,
|
|
324
366
|
};
|
|
325
367
|
}
|
|
326
|
-
export const SKIP_PATH_SEGMENTS = new Set(["api", "v1", "v2", "v3", "public"]);
|
|
327
|
-
/** Extract the primary resource name from an endpoint path (e.g. "/api/v1/orders/{id}" → "orders"). */
|
|
328
|
-
export function extractResourceFromPath(endpointPath) {
|
|
329
|
-
const segments = endpointPath.split("/").filter(Boolean);
|
|
330
|
-
const meaningful = segments.filter(s => !s.startsWith("{") && !SKIP_PATH_SEGMENTS.has(s));
|
|
331
|
-
return meaningful[meaningful.length - 1] || "unknown";
|
|
332
|
-
}
|
|
333
|
-
/**
|
|
334
|
-
* Resolve incomplete diff-parsed endpoint paths against the authoritative
|
|
335
|
-
* scanned endpoint catalog. Route decorators in diffs often contain only the
|
|
336
|
-
* handler-relative fragment (e.g. "/{order_id}") because the router prefix
|
|
337
|
-
* (e.g. APIRouter(prefix="/api/v1/orders")) is outside the diff hunk.
|
|
338
|
-
*
|
|
339
|
-
* For each diff endpoint whose path doesn't match any known endpoint exactly,
|
|
340
|
-
* find the scanned endpoint whose full path ends with the diff path and shares
|
|
341
|
-
* the same HTTP method. Mutates the input array in place.
|
|
342
|
-
*/
|
|
343
|
-
export function resolveEndpointPaths(diffEndpoints, knownEndpoints) {
|
|
344
|
-
if (diffEndpoints.length === 0 || knownEndpoints.length === 0)
|
|
345
|
-
return;
|
|
346
|
-
for (const ep of diffEndpoints) {
|
|
347
|
-
const alreadyFull = knownEndpoints.some(s => s.path === ep.path);
|
|
348
|
-
if (alreadyFull)
|
|
349
|
-
continue;
|
|
350
|
-
const candidates = knownEndpoints.filter(s => s.path.endsWith(ep.path) &&
|
|
351
|
-
s.path !== ep.path &&
|
|
352
|
-
s.methods.some(m => m.method === ep.method));
|
|
353
|
-
if (candidates.length === 1) {
|
|
354
|
-
ep.path = candidates[0].path;
|
|
355
|
-
}
|
|
356
|
-
else if (candidates.length > 1) {
|
|
357
|
-
const byFile = candidates.filter(s => s.methods.some(m => m.method === ep.method &&
|
|
358
|
-
m.sourceFile != null &&
|
|
359
|
-
(m.sourceFile === ep.sourceFile ||
|
|
360
|
-
m.sourceFile.endsWith(ep.sourceFile) ||
|
|
361
|
-
ep.sourceFile.endsWith(m.sourceFile))));
|
|
362
|
-
if (byFile.length === 1) {
|
|
363
|
-
ep.path = byFile[0].path;
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @ts-ignore
|
|
2
|
-
import { nextjsFileToApiPath, parseRouteLine, parseFileEndpoints,
|
|
2
|
+
import { nextjsFileToApiPath, parseRouteLine, parseFileEndpoints, extractResourceFromPath, classifyEndpointsByChangedFiles } from "./routeParsers.js";
|
|
3
3
|
describe("nextjsFileToApiPath", () => {
|
|
4
4
|
it("converts pages/api route to API path", () => {
|
|
5
5
|
expect(nextjsFileToApiPath("pages/api/users/index.ts")).toBe("/api/users");
|
|
@@ -47,6 +47,18 @@ describe("parseRouteLine", () => {
|
|
|
47
47
|
expect(result).not.toBeNull();
|
|
48
48
|
expect(result.method).toBe("GET");
|
|
49
49
|
});
|
|
50
|
+
it("parses Next.js guard clause (req.method !== 'POST')", () => {
|
|
51
|
+
const result = parseRouteLine(' if (req.method !== "POST") {', "pages/api/v1/collections/[id]/transfer.ts");
|
|
52
|
+
expect(result).not.toBeNull();
|
|
53
|
+
expect(result.method).toBe("POST");
|
|
54
|
+
expect(result.path).toBe("/api/v1/collections/{id}/transfer");
|
|
55
|
+
});
|
|
56
|
+
it("parses Next.js guard clause with != operator", () => {
|
|
57
|
+
const result = parseRouteLine(" if (req.method != 'DELETE') {", "pages/api/v1/items/[id].ts");
|
|
58
|
+
expect(result).not.toBeNull();
|
|
59
|
+
expect(result.method).toBe("DELETE");
|
|
60
|
+
expect(result.path).toBe("/api/v1/items/{id}");
|
|
61
|
+
});
|
|
50
62
|
it("parses Gin route", () => {
|
|
51
63
|
const result = parseRouteLine('r.GET("/users", getUsers)', "main.go");
|
|
52
64
|
expect(result).toEqual({ method: "GET", path: "/users", sourceFile: "main.go" });
|
|
@@ -246,71 +258,6 @@ async def get_product(): pass
|
|
|
246
258
|
expect(eps).toContainEqual({ method: "GET", path: "/products/{product_id}", sourceFile: "routers/product.py" });
|
|
247
259
|
});
|
|
248
260
|
});
|
|
249
|
-
describe("resolveEndpointPaths", () => {
|
|
250
|
-
const knownEndpoints = [
|
|
251
|
-
{ path: "/api/v1/orders", methods: [{ method: "POST", sourceFile: "routers/order.py" }, { method: "GET", sourceFile: "routers/order.py" }] },
|
|
252
|
-
{ path: "/api/v1/orders/{order_id}", methods: [{ method: "GET", sourceFile: "routers/order.py" }, { method: "PUT", sourceFile: "routers/order.py" }, { method: "DELETE", sourceFile: "routers/order.py" }] },
|
|
253
|
-
{ path: "/api/v1/products", methods: [{ method: "POST", sourceFile: "routers/product.py" }, { method: "GET", sourceFile: "routers/product.py" }] },
|
|
254
|
-
{ path: "/api/v1/products/{product_id}", methods: [{ method: "GET", sourceFile: "routers/product.py" }, { method: "PUT", sourceFile: "routers/product.py" }, { method: "DELETE", sourceFile: "routers/product.py" }] },
|
|
255
|
-
];
|
|
256
|
-
it("resolves router-relative path to full API path", () => {
|
|
257
|
-
const eps = [{ method: "PUT", path: "/{order_id}", sourceFile: "routers/order.py" }];
|
|
258
|
-
resolveEndpointPaths(eps, knownEndpoints);
|
|
259
|
-
expect(eps[0].path).toBe("/api/v1/orders/{order_id}");
|
|
260
|
-
});
|
|
261
|
-
it("leaves already-full paths unchanged", () => {
|
|
262
|
-
const eps = [{ method: "POST", path: "/api/v1/orders", sourceFile: "routers/order.py" }];
|
|
263
|
-
resolveEndpointPaths(eps, knownEndpoints);
|
|
264
|
-
expect(eps[0].path).toBe("/api/v1/orders");
|
|
265
|
-
});
|
|
266
|
-
it("resolves multiple endpoints in one call", () => {
|
|
267
|
-
const eps = [
|
|
268
|
-
{ method: "PUT", path: "/{order_id}", sourceFile: "routers/order.py" },
|
|
269
|
-
{ method: "DELETE", path: "/{order_id}", sourceFile: "routers/order.py" },
|
|
270
|
-
];
|
|
271
|
-
resolveEndpointPaths(eps, knownEndpoints);
|
|
272
|
-
expect(eps[0].path).toBe("/api/v1/orders/{order_id}");
|
|
273
|
-
expect(eps[1].path).toBe("/api/v1/orders/{order_id}");
|
|
274
|
-
});
|
|
275
|
-
it("disambiguates by sourceFile when suffix matches multiple endpoints", () => {
|
|
276
|
-
const ambiguousKnown = [
|
|
277
|
-
{ path: "/api/v1/orders/{id}", methods: [{ method: "PUT", sourceFile: "routers/order.py" }] },
|
|
278
|
-
{ path: "/api/v1/products/{id}", methods: [{ method: "PUT", sourceFile: "routers/product.py" }] },
|
|
279
|
-
];
|
|
280
|
-
const eps = [{ method: "PUT", path: "/{id}", sourceFile: "routers/order.py" }];
|
|
281
|
-
resolveEndpointPaths(eps, ambiguousKnown);
|
|
282
|
-
expect(eps[0].path).toBe("/api/v1/orders/{id}");
|
|
283
|
-
});
|
|
284
|
-
it("does not resolve when no match found", () => {
|
|
285
|
-
const eps = [{ method: "PATCH", path: "/{id}", sourceFile: "routers/unknown.py" }];
|
|
286
|
-
resolveEndpointPaths(eps, knownEndpoints);
|
|
287
|
-
expect(eps[0].path).toBe("/{id}");
|
|
288
|
-
});
|
|
289
|
-
it("does not resolve when ambiguous and sourceFile does not disambiguate", () => {
|
|
290
|
-
const ambiguousKnown = [
|
|
291
|
-
{ path: "/api/v1/orders/{id}", methods: [{ method: "PUT" }] },
|
|
292
|
-
{ path: "/api/v1/products/{id}", methods: [{ method: "PUT" }] },
|
|
293
|
-
];
|
|
294
|
-
const eps = [{ method: "PUT", path: "/{id}", sourceFile: "shared.py" }];
|
|
295
|
-
resolveEndpointPaths(eps, ambiguousKnown);
|
|
296
|
-
expect(eps[0].path).toBe("/{id}");
|
|
297
|
-
});
|
|
298
|
-
it("handles empty arrays gracefully", () => {
|
|
299
|
-
const eps = [];
|
|
300
|
-
resolveEndpointPaths(eps, knownEndpoints);
|
|
301
|
-
expect(eps).toEqual([]);
|
|
302
|
-
const eps2 = [{ method: "PUT", path: "/{id}", sourceFile: "x.py" }];
|
|
303
|
-
resolveEndpointPaths(eps2, []);
|
|
304
|
-
expect(eps2[0].path).toBe("/{id}");
|
|
305
|
-
});
|
|
306
|
-
it("resolves collection-level relative paths (e.g. empty string becomes base)", () => {
|
|
307
|
-
const eps = [{ method: "POST", path: "", sourceFile: "routers/order.py" }];
|
|
308
|
-
resolveEndpointPaths(eps, knownEndpoints);
|
|
309
|
-
// Empty string — endsWith("") is always true, so multiple matches.
|
|
310
|
-
// sourceFile should disambiguate to orders.
|
|
311
|
-
expect(eps[0].path).toBe("/api/v1/orders");
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
261
|
describe("extractResourceFromPath", () => {
|
|
315
262
|
it("extracts resource from simple path", () => {
|
|
316
263
|
expect(extractResourceFromPath("/orders")).toBe("orders");
|
|
@@ -339,3 +286,182 @@ describe("extractResourceFromPath", () => {
|
|
|
339
286
|
expect(extractResourceFromPath("api/v1/orders")).toBe("orders");
|
|
340
287
|
});
|
|
341
288
|
});
|
|
289
|
+
describe("classifyEndpointsByChangedFiles", () => {
|
|
290
|
+
function makeDiffDataForClassify(opts) {
|
|
291
|
+
return {
|
|
292
|
+
currentBranch: "feature/test",
|
|
293
|
+
baseBranch: "origin/main",
|
|
294
|
+
changedFiles: opts.changedFiles,
|
|
295
|
+
diffContent: "",
|
|
296
|
+
diffStat: "",
|
|
297
|
+
newFiles: opts.newFiles ?? [],
|
|
298
|
+
deletedFiles: opts.deletedFiles ?? [],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const scannedEndpoints = [
|
|
302
|
+
{ path: "/api/v1/orders/{order_id}", methods: ["GET", "PATCH"], sourceFile: "src/routes/orders.py" },
|
|
303
|
+
{ path: "/api/v1/products/{product_id}", methods: ["GET", "POST"], sourceFile: "src/routes/products.py" },
|
|
304
|
+
{ path: "/api/v1/users", methods: ["GET"], sourceFile: "src/routes/users.py" },
|
|
305
|
+
];
|
|
306
|
+
it("classifies modified endpoints from changed files", () => {
|
|
307
|
+
const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/orders.py"] });
|
|
308
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
|
|
309
|
+
expect(result.changedEndpoints).toHaveLength(1);
|
|
310
|
+
expect(result.changedEndpoints[0].path).toBe("/api/v1/orders/{order_id}");
|
|
311
|
+
expect(result.newEndpoints).toHaveLength(0);
|
|
312
|
+
expect(result.removedEndpoints).toHaveLength(0);
|
|
313
|
+
});
|
|
314
|
+
it("classifies new endpoints from newly-created files", () => {
|
|
315
|
+
const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/products.py"], newFiles: ["src/routes/products.py"] });
|
|
316
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
|
|
317
|
+
expect(result.newEndpoints).toHaveLength(1);
|
|
318
|
+
expect(result.newEndpoints[0].path).toBe("/api/v1/products/{product_id}");
|
|
319
|
+
expect(result.changedEndpoints).toHaveLength(0);
|
|
320
|
+
});
|
|
321
|
+
it("puts files without endpoints into unmatchedFiles", () => {
|
|
322
|
+
const diff = makeDiffDataForClassify({ changedFiles: ["src/models/order.py", "src/routes/orders.py"] });
|
|
323
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
|
|
324
|
+
expect(result.unmatchedFiles).toEqual(["src/models/order.py"]);
|
|
325
|
+
expect(result.changedEndpoints).toHaveLength(1);
|
|
326
|
+
});
|
|
327
|
+
it("skips deleted files from changed classification", () => {
|
|
328
|
+
const deletedEndpoints = [{ path: "/api/v1/legacy", methods: ["GET"], sourceFile: "src/routes/legacy.py" }];
|
|
329
|
+
const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/legacy.py", "src/routes/orders.py"], deletedFiles: ["src/routes/legacy.py"] });
|
|
330
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deletedEndpoints);
|
|
331
|
+
expect(result.removedEndpoints).toHaveLength(1);
|
|
332
|
+
expect(result.removedEndpoints[0].path).toBe("/api/v1/legacy");
|
|
333
|
+
expect(result.changedEndpoints).toHaveLength(1);
|
|
334
|
+
expect(result.changedEndpoints[0].path).toBe("/api/v1/orders/{order_id}");
|
|
335
|
+
});
|
|
336
|
+
it("handles multiple endpoints in the same file", () => {
|
|
337
|
+
const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/orders.py"] });
|
|
338
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
|
|
339
|
+
expect(result.changedEndpoints).toHaveLength(1);
|
|
340
|
+
expect(result.changedEndpoints[0].methods).toEqual(["GET", "PATCH"]);
|
|
341
|
+
});
|
|
342
|
+
it("returns empty results when no changed files match endpoints", () => {
|
|
343
|
+
const diff = makeDiffDataForClassify({ changedFiles: ["README.md", "package.json"] });
|
|
344
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
|
|
345
|
+
expect(result.changedEndpoints).toHaveLength(0);
|
|
346
|
+
expect(result.newEndpoints).toHaveLength(0);
|
|
347
|
+
expect(result.removedEndpoints).toHaveLength(0);
|
|
348
|
+
expect(result.unmatchedFiles).toEqual(["README.md", "package.json"]);
|
|
349
|
+
});
|
|
350
|
+
it("computes affectedServices from changedFiles", () => {
|
|
351
|
+
const diff = makeDiffDataForClassify({ changedFiles: ["services/orders/routes.py", "services/users/models.py"] });
|
|
352
|
+
const result = classifyEndpointsByChangedFiles(diff, []);
|
|
353
|
+
expect(result.affectedServices).toContain("orders");
|
|
354
|
+
expect(result.affectedServices).toContain("users");
|
|
355
|
+
});
|
|
356
|
+
it("propagates branch metadata from diffData", () => {
|
|
357
|
+
const diff = makeDiffDataForClassify({ changedFiles: [] });
|
|
358
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
|
|
359
|
+
expect(result.currentBranch).toBe("feature/test");
|
|
360
|
+
expect(result.baseBranch).toBe("origin/main");
|
|
361
|
+
expect(result.changedFiles).toEqual([]);
|
|
362
|
+
});
|
|
363
|
+
it("handles empty scannedEndpoints (all files unmatched)", () => {
|
|
364
|
+
const diff = makeDiffDataForClassify({ changedFiles: ["src/routes/orders.py"] });
|
|
365
|
+
const result = classifyEndpointsByChangedFiles(diff, []);
|
|
366
|
+
expect(result.changedEndpoints).toHaveLength(0);
|
|
367
|
+
expect(result.newEndpoints).toHaveLength(0);
|
|
368
|
+
expect(result.removedEndpoints).toHaveLength(0);
|
|
369
|
+
expect(result.unmatchedFiles).toEqual(["src/routes/orders.py"]);
|
|
370
|
+
});
|
|
371
|
+
it("does not double-count a file that is both new and has endpoints", () => {
|
|
372
|
+
const diff = makeDiffDataForClassify({
|
|
373
|
+
changedFiles: ["src/routes/products.py", "src/routes/orders.py"],
|
|
374
|
+
newFiles: ["src/routes/products.py"],
|
|
375
|
+
});
|
|
376
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
|
|
377
|
+
// products.py is new → newEndpoints; orders.py is changed → changedEndpoints
|
|
378
|
+
expect(result.newEndpoints).toHaveLength(1);
|
|
379
|
+
expect(result.newEndpoints[0].sourceFile).toBe("src/routes/products.py");
|
|
380
|
+
expect(result.changedEndpoints).toHaveLength(1);
|
|
381
|
+
expect(result.changedEndpoints[0].sourceFile).toBe("src/routes/orders.py");
|
|
382
|
+
});
|
|
383
|
+
it("includes deleted-file endpoints in removedEndpoints when not in current catalog", () => {
|
|
384
|
+
const deleted = [
|
|
385
|
+
{ path: "/api/v1/removed", methods: ["GET", "POST"], sourceFile: "src/routes/removed.py" },
|
|
386
|
+
];
|
|
387
|
+
const diff = makeDiffDataForClassify({
|
|
388
|
+
changedFiles: ["src/routes/removed.py"],
|
|
389
|
+
deletedFiles: ["src/routes/removed.py"],
|
|
390
|
+
});
|
|
391
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deleted);
|
|
392
|
+
expect(result.removedEndpoints).toHaveLength(1);
|
|
393
|
+
expect(result.removedEndpoints[0].path).toBe("/api/v1/removed");
|
|
394
|
+
expect(result.removedEndpoints[0].methods).toEqual(["GET", "POST"]);
|
|
395
|
+
// The deleted file should NOT appear in changedEndpoints or unmatchedFiles
|
|
396
|
+
expect(result.changedEndpoints.every(ep => ep.sourceFile !== "src/routes/removed.py")).toBe(true);
|
|
397
|
+
expect(result.unmatchedFiles).not.toContain("src/routes/removed.py");
|
|
398
|
+
});
|
|
399
|
+
it("filters moved endpoints from removedEndpoints when they exist in current catalog", () => {
|
|
400
|
+
// Endpoint was in a deleted file but the same METHOD+path exists in scanned catalog
|
|
401
|
+
// (moved to another file) — should NOT appear in removedEndpoints
|
|
402
|
+
const deleted = [
|
|
403
|
+
{ path: "/api/v1/orders/{order_id}", methods: ["GET", "DELETE"], sourceFile: "src/routes/old-orders.py" },
|
|
404
|
+
];
|
|
405
|
+
const diff = makeDiffDataForClassify({
|
|
406
|
+
changedFiles: ["src/routes/old-orders.py"],
|
|
407
|
+
deletedFiles: ["src/routes/old-orders.py"],
|
|
408
|
+
});
|
|
409
|
+
// scannedEndpoints has GET /api/v1/orders/{order_id} (from orders.py) → moved, filter out
|
|
410
|
+
// DELETE /api/v1/orders/{order_id} is NOT in scannedEndpoints → truly removed, keep
|
|
411
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deleted);
|
|
412
|
+
expect(result.removedEndpoints).toHaveLength(1);
|
|
413
|
+
expect(result.removedEndpoints[0].path).toBe("/api/v1/orders/{order_id}");
|
|
414
|
+
expect(result.removedEndpoints[0].methods).toEqual(["DELETE"]);
|
|
415
|
+
});
|
|
416
|
+
it("filters out entirely moved endpoints (all methods exist in catalog)", () => {
|
|
417
|
+
const deleted = [
|
|
418
|
+
{ path: "/api/v1/orders/{order_id}", methods: ["GET", "PATCH"], sourceFile: "src/routes/old-orders.py" },
|
|
419
|
+
];
|
|
420
|
+
const diff = makeDiffDataForClassify({
|
|
421
|
+
changedFiles: ["src/routes/old-orders.py"],
|
|
422
|
+
deletedFiles: ["src/routes/old-orders.py"],
|
|
423
|
+
});
|
|
424
|
+
// Both GET and PATCH for /api/v1/orders/{order_id} exist in scannedEndpoints → fully filtered
|
|
425
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deleted);
|
|
426
|
+
expect(result.removedEndpoints).toHaveLength(0);
|
|
427
|
+
});
|
|
428
|
+
it("treats MULTI sentinel in current catalog as wildcard when filtering moved endpoints", () => {
|
|
429
|
+
// Deleted file had explicit GET+POST methods, but current catalog has MULTI for the same path
|
|
430
|
+
// (e.g. moved to a Next.js catch-all handler) — should be fully filtered out
|
|
431
|
+
const catalogWithMulti = [
|
|
432
|
+
...scannedEndpoints,
|
|
433
|
+
{ path: "/api/v1/legacy", methods: ["MULTI"], sourceFile: "pages/api/legacy.ts" },
|
|
434
|
+
];
|
|
435
|
+
const deleted = [
|
|
436
|
+
{ path: "/api/v1/legacy", methods: ["GET", "POST"], sourceFile: "src/routes/legacy.py" },
|
|
437
|
+
];
|
|
438
|
+
const diff = makeDiffDataForClassify({
|
|
439
|
+
changedFiles: ["src/routes/legacy.py"],
|
|
440
|
+
deletedFiles: ["src/routes/legacy.py"],
|
|
441
|
+
});
|
|
442
|
+
const result = classifyEndpointsByChangedFiles(diff, catalogWithMulti, deleted);
|
|
443
|
+
expect(result.removedEndpoints).toHaveLength(0);
|
|
444
|
+
});
|
|
445
|
+
it("treats MULTI sentinel in deleted endpoints as wildcard when path exists in catalog", () => {
|
|
446
|
+
// Deleted file was a Next.js catch-all (MULTI), but path now exists with concrete methods
|
|
447
|
+
// in the current catalog — should be filtered out as moved, not flagged as removed
|
|
448
|
+
const deleted = [
|
|
449
|
+
{ path: "/api/v1/orders/{order_id}", methods: ["MULTI"], sourceFile: "pages/api/orders/[order_id].ts" },
|
|
450
|
+
];
|
|
451
|
+
const diff = makeDiffDataForClassify({
|
|
452
|
+
changedFiles: ["pages/api/orders/[order_id].ts"],
|
|
453
|
+
deletedFiles: ["pages/api/orders/[order_id].ts"],
|
|
454
|
+
});
|
|
455
|
+
// scannedEndpoints already has GET+PATCH for /api/v1/orders/{order_id}
|
|
456
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints, deleted);
|
|
457
|
+
expect(result.removedEndpoints).toHaveLength(0);
|
|
458
|
+
});
|
|
459
|
+
it("defaults removedEndpoints to empty when deletedFileEndpoints is omitted", () => {
|
|
460
|
+
const diff = makeDiffDataForClassify({
|
|
461
|
+
changedFiles: ["src/routes/legacy.py"],
|
|
462
|
+
deletedFiles: ["src/routes/legacy.py"],
|
|
463
|
+
});
|
|
464
|
+
const result = classifyEndpointsByChangedFiles(diff, scannedEndpoints);
|
|
465
|
+
expect(result.removedEndpoints).toEqual([]);
|
|
466
|
+
});
|
|
467
|
+
});
|
|
@@ -87,6 +87,9 @@ export function inferResourceRelationships(endpoints) {
|
|
|
87
87
|
// ── Signal 2: FK fields in request body interactions ──
|
|
88
88
|
// Collect all known resource names first so we can validate FK targets.
|
|
89
89
|
const knownResources = new Set(endpoints.map(ep => extractResourceName(ep.path)).filter(Boolean));
|
|
90
|
+
// Map singular form → actual resource name to handle irregular plurals.
|
|
91
|
+
// e.g. singularize("companies") = "company", so "company_id" resolves to "companies".
|
|
92
|
+
const singularToResource = new Map([...knownResources].map(r => [singularize(r), r]));
|
|
90
93
|
for (const ep of endpoints) {
|
|
91
94
|
const resource = extractResourceName(ep.path);
|
|
92
95
|
if (!resource)
|
|
@@ -104,14 +107,19 @@ export function inferResourceRelationships(endpoints) {
|
|
|
104
107
|
if (!key.endsWith("_id"))
|
|
105
108
|
continue;
|
|
106
109
|
const depSingular = key.slice(0, -3); // "product_id" → "product"
|
|
107
|
-
const depPlural = depSingular + "s"; //
|
|
108
|
-
// Only create relationship when the dependency actually exists as an endpoint
|
|
110
|
+
const depPlural = depSingular + "s"; // naive plural, fine for regular nouns
|
|
111
|
+
// Only create relationship when the dependency actually exists as an endpoint.
|
|
112
|
+
// Check naive plural first (products), then exact singular (product),
|
|
113
|
+
// then reverse-singularize to catch irregular plurals (company → companies).
|
|
109
114
|
if (knownResources.has(depPlural)) {
|
|
110
115
|
addRelationship(resource, depPlural);
|
|
111
116
|
}
|
|
112
117
|
else if (knownResources.has(depSingular)) {
|
|
113
118
|
addRelationship(resource, depSingular);
|
|
114
119
|
}
|
|
120
|
+
else if (singularToResource.has(depSingular)) {
|
|
121
|
+
addRelationship(resource, singularToResource.get(depSingular));
|
|
122
|
+
}
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
}
|
package/build/utils/versions.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyramp/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"main": "build/index.js",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./build/index.js",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
57
57
|
"@playwright/test": "^1.55.0",
|
|
58
|
-
"@skyramp/skyramp": "1.3.
|
|
58
|
+
"@skyramp/skyramp": "1.3.23",
|
|
59
59
|
"dockerode": "^5.0.0",
|
|
60
60
|
"fast-glob": "^3.3.3",
|
|
61
61
|
"js-yaml": "^4.1.1",
|