@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.
@@ -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 function parseEndpointsFromDiff(diffData) {
271
- const lines = diffData.diffContent.split("\n");
272
- const addedRoutes = [];
273
- const removedKeys = new Set();
274
- let currentFile = "";
275
- for (const line of lines) {
276
- const fileMatch = line.match(/^diff --git a\/.+ b\/(.+)$/);
277
- if (fileMatch) {
278
- currentFile = fileMatch[1];
279
- continue;
280
- }
281
- if (line.startsWith("+") && !line.startsWith("+++")) {
282
- const ep = parseRouteLine(line, currentFile);
283
- if (ep)
284
- addedRoutes.push(ep);
285
- }
286
- else if (line.startsWith("-") && !line.startsWith("---")) {
287
- const ep = parseRouteLine(line, currentFile);
288
- if (ep)
289
- removedKeys.add(`${ep.method} ${ep.path}`);
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 addedRouteKeys = new Set(addedRoutes.map((r) => r.path));
311
+ const changedEndpoints = [];
312
+ const newEndpoints = [];
313
+ const unmatchedFiles = [];
293
314
  for (const file of diffData.changedFiles) {
294
- const apiPath = nextjsFileToApiPath(file);
295
- if (apiPath && !addedRouteKeys.has(apiPath)) {
296
- addedRoutes.push({ method: "MULTI", path: apiPath, sourceFile: file });
297
- addedRouteKeys.add(apiPath);
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
- const newEndpoints = [];
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
- newEndpoints.push(ep);
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, resolveEndpointPaths, extractResourceFromPath } from "./routeParsers.js";
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"; // "products"
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
  }
@@ -1,3 +1,3 @@
1
- export const SKYRAMP_IMAGE_VERSION = "v1.3.22";
1
+ export const SKYRAMP_IMAGE_VERSION = "v1.3.23";
2
2
  export const EXECUTOR_DOCKER_IMAGE = `skyramp/executor:${SKYRAMP_IMAGE_VERSION}`;
3
3
  export const WORKER_DOCKER_IMAGE = `skyramp/worker:${SKYRAMP_IMAGE_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/mcp",
3
- "version": "0.1.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.22",
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",